Compare commits

..

1 commit
main ... main

44 changed files with 1228 additions and 2721 deletions

View file

@ -2,24 +2,6 @@
HOST=0.0.0.0
PORT=8080
REDIS_URL=redis://dragonfly:6379
REDIS_TTL=3600 # seconds
# this is only the default value if non is give in /id
LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081
# Required if you want to enable reviews from reviewdb
REVIEW_DB=true
#Timezone api url, aka: https://git.creations.works/creations/timezoneDB
TIMEZONE_API_URL=
# https://www.steamgriddb.com/api/v2, if you want games to have images
STEAMGRIDDB_API_KEY=steamgrid_api_key
# https://plausible.io
PLAUSIBLE_SCRIPT_HTML=''

View file

@ -1,24 +0,0 @@
name: Code quality checks
on:
push:
pull_request:
jobs:
biome:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
- name: Install Dependencies
run: bun install
- name: Run Biome with verbose output
run: bunx biome ci . --verbose

5
.gitignore vendored
View file

@ -1,7 +1,4 @@
/node_modules
bun.lock
.env
logs/
.vscode/
robots.txt
public/custom
logs/

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"github-enterprise.uri": "https://git.creations.works"
}

View file

@ -1,38 +0,0 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:latest AS base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# [optional] tests & build
ENV NODE_ENV=production
# copy production dependencies and source code into final image
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/src ./src
COPY --from=prerelease /usr/src/app/public ./public
COPY --from=prerelease /usr/src/app/package.json .
COPY --from=prerelease /usr/src/app/tsconfig.json .
COPY --from=prerelease /usr/src/app/config ./config
COPY --from=prerelease /usr/src/app/types ./types
RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs
USER bun
ENTRYPOINT [ "bun", "run", "start" ]

28
LICENSE
View file

@ -1,28 +0,0 @@
BSD 3-Clause License
Copyright (c) 2025, creations.works
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

146
README.md
View file

@ -1,145 +1,3 @@
# Discord Profile Page
# Cool little discord profile page
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun.
# Preview
https://creations.works
---
## Requirements
This project depends on the following services to function properly:
### 1. Lanyard Backend
This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance to fetch real-time Discord presence data.
Make sure the Lanyard instance is running and accessible before using this.
### 2. Redis Instance
A Redis-compatible key-value store is required to cache third-party data (e.g., SteamGridDB icons).
I recommend [Dragonfly](https://www.dragonflydb.io/), a high-performance drop-in replacement for Redis.
### 3. Badge API
A lightweight API to render Discord-style badges.
>Only needed if you want to show badges on profiles:
https://git.creations.works/creations/badgeAPI
### 4. SteamGridDB
>Only needed if you want to fetch game icons that Discord doesnt provide:
https://www.steamgriddb.com/api/v2
---
## Getting Started
### 1. Clone & Install
```bash
git clone https://git.creations.works/creations/profilePage.git
cd profilePage
bun install
```
### 2. Configure Environment
Copy the example environment file and update it:
```bash
cp .env.example .env
```
#### `.env` Variables
| Variable | Description |
|-----------------------|-----------------------------------------------------------------------------|
| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) |
| `PORT` | Port to run the server on (default: `8080`) |
| `REDIS_URL` | Redis connection string |
| `LANYARD_USER_ID` | Your Discord user ID, for the default page |
| `LANYARD_INSTANCE` | Endpoint of the Lanyard instance |
| `BADGE_API_URL` | Badge API URL ([badgeAPI](https://git.creations.works/creations/badgeAPI)) |
| `REVIEW_DB` | Enables showing reviews from reviewdb on user pages |
| `TIMEZONE_API_URL` | Enables showing times from [timezoneDB](https://git.creations.works/creations/timezoneDB-rs) |
| `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons |
#### Optional Lanyard KV Variables (per-user customization)
These can be defined in Lanyard's KV store to customize the page:
| Variable | Description |
|-----------|--------------------------------------------------------------------|
| `snow` | Enables snow background (`true` / `false`) |
| `rain` | Enables rain background (`true` / `false`) |
| `stars` | Enables starfield background (`true` / `false`) |
| `badges` | Enables badge fetching (`true` / `false`) |
| `readme` | URL to a README displayed on the profile (`.md` or `.html`) |
| `css` | URL to a css to change styles on the page, no import or require allowed |
| `optout` | Allows users to stop sharing there profile on the website (`true` / `false`) |
| `reviews` | Enables reviews from reviewdb (`true` / `false`) |
| `timezone`| Enables the showing of the current time from the timezone db API (`true` / `false`) |
| `timezone_12` | Sets the timezone to default show 12h format, default is 24h |
---
### 3. Start the Instance
```bash
bun run start
```
---
## Optional: Analytics with Plausible
You can enable [Plausible Analytics](https://plausible.io) tracking by setting a script snippet in your environment.
### `.env` Variable
| Variable | Description |
|-------------------------|------------------------------------------------------------------------|
| `PLAUSIBLE_SCRIPT_HTML` | Full `<script>` tag(s) to inject into the `<head>` for analytics |
#### Example
```env
PLAUSIBLE_SCRIPT_HTML='<script defer data-domain="example.com" src="https://plausible.example.com/js/script.js"></script><script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>'
```
- The script will only be injected if this variable is set.
- Plausible provides the correct script when you add a domain.
- Be sure to wrap it in single quotes (`'`) so it works in `.env`.
---
## Docker Support
### Build & Start with Docker Compose
```bash
docker compose up -d --build
```
Make sure the `.env` file is configured correctly before starting the container.
---
## Routes
These are the main public routes exposed by the server:
| Route | Description |
|---------|-----------------------------------------------------------------------------|
| `/` | Loads the profile page for the default Discord user defined in `.env` (`LANYARD_USER_ID`) |
| `/[id]` | Loads the profile page for a specific Discord user ID passed in the URL |
> Example: `https://creations.works/209830981060788225` shows the profile of that specific user.
---
## License
[BSD 3](LICENSE)
E

View file

@ -1,44 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": true,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineEnding": "lf"
},
"organizeImports": {
"enabled": true
},
"css": {
"formatter": {
"indentStyle": "tab",
"lineEnding": "lf"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"indentStyle": "tab",
"lineEnding": "lf",
"jsxQuoteStyle": "double",
"semicolons": "always"
}
}
}

View file

@ -1,29 +0,0 @@
services:
profile-page:
container_name: profilePage
build:
context: .
restart: unless-stopped
ports:
- "${PORT:-6600}:${PORT:-6600}"
env_file:
- .env
networks:
- profilePage-network
dragonfly:
image: 'docker.dragonflydb.io/dragonflydb/dragonfly'
restart: unless-stopped
ulimits:
memlock: -1
volumes:
- dragonflydata:/data
networks:
- profilePage-network
volumes:
dragonflydata:
networks:
profilePage-network:
driver: bridge

View file

@ -1,66 +1,12 @@
import { echo } from "@atums/echo";
const environment: Environment = {
port: Number.parseInt(process.env.PORT || "8080", 10),
export const environment: Environment = {
port: parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0",
development:
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
process.env.NODE_ENV === "development" ||
process.argv.includes("--dev"),
};
const redisTtl: number = process.env.REDIS_TTL
? Number.parseInt(process.env.REDIS_TTL, 10)
: 60 * 60 * 1; // 1 hour
const lanyardConfig: LanyardConfig = {
export const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "",
instance: process.env.LANYARD_INSTANCE || "",
};
const reviewDb = {
enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1",
url: "https://manti.vendicated.dev/api/reviewdb",
};
const timezoneAPIUrl: string | null = process.env.TIMEZONE_API_URL || null;
const badgeApi: string | null = process.env.BADGE_API_URL || null;
const steamGridDbKey: string | undefined = process.env.STEAMGRIDDB_API_KEY;
const plausibleScript: string | null =
process.env.PLAUSIBLE_SCRIPT_HTML?.trim() || null;
function verifyRequiredVariables(): void {
const requiredVariables = [
"HOST",
"PORT",
"LANYARD_USER_ID",
"LANYARD_INSTANCE",
];
let hasError = false;
for (const key of requiredVariables) {
const value = process.env[key];
if (value === undefined || value.trim() === "") {
echo.error(`Missing or empty environment variable: ${key}`);
hasError = true;
}
}
if (hasError) {
process.exit(1);
}
}
export {
environment,
lanyardConfig,
redisTtl,
reviewDb,
timezoneAPIUrl,
badgeApi,
steamGridDbKey,
plausibleScript,
verifyRequiredVariables,
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
};

132
eslint.config.js Normal file
View file

@ -0,0 +1,132 @@
import pluginJs from "@eslint/js";
import tseslintPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import prettier from "eslint-plugin-prettier";
import promisePlugin from "eslint-plugin-promise";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import unicorn from "eslint-plugin-unicorn";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
{
files: ["**/*.{js,mjs,cjs}"],
languageOptions: {
globals: globals.node,
},
...pluginJs.configs.recommended,
plugins: {
"simple-import-sort": simpleImportSort,
"unused-imports": unusedImports,
promise: promisePlugin,
prettier: prettier,
unicorn: unicorn,
},
rules: {
"eol-last": ["error", "always"],
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"prettier/prettier": [
"error",
{
useTabs: true,
tabWidth: 4,
},
],
indent: ["error", "tab", { SwitchCase: 1 }],
"unicorn/filename-case": [
"error",
{
case: "camelCase",
},
],
},
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
globals: globals.node,
},
plugins: {
"@typescript-eslint": tseslintPlugin,
"simple-import-sort": simpleImportSort,
"unused-imports": unusedImports,
promise: promisePlugin,
prettier: prettier,
unicorn: unicorn,
},
rules: {
...tseslintPlugin.configs.recommended.rules,
quotes: ["error", "double"],
"eol-last": ["error", "always"],
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"prettier/prettier": [
"error",
{
useTabs: true,
tabWidth: 4,
},
],
indent: ["error", "tab", { SwitchCase: 1 }],
"unicorn/filename-case": [
"error",
{
case: "camelCase",
},
],
"@typescript-eslint/explicit-function-return-type": ["error"],
"@typescript-eslint/explicit-module-boundary-types": ["error"],
"@typescript-eslint/typedef": [
"error",
{
arrowParameter: true,
variableDeclaration: true,
propertyDeclaration: true,
memberVariableDeclaration: true,
parameter: true,
},
],
},
},
];

View file

@ -1,39 +0,0 @@
{
"directory": "logs",
"level": "debug",
"disableFile": false,
"rotate": true,
"maxFiles": 3,
"console": true,
"consoleColor": true,
"dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
"timezone": "local",
"silent": false,
"pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}",
"levelColor": {
"debug": "blue",
"info": "green",
"warn": "yellow",
"error": "red",
"fatal": "red"
},
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
"customColors": {
"GET": "green",
"POST": "blue",
"PUT": "yellow",
"DELETE": "red",
"PATCH": "cyan",
"HEAD": "magenta",
"OPTIONS": "white",
"TRACE": "gray"
},
"prettyPrint": true
}

View file

@ -5,20 +5,31 @@
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun run --hot src/index.ts --dev",
"lint": "bunx biome ci . --verbose",
"lint:fix": "bunx biome check --fix",
"lint": "eslint",
"lint:fix": "bun lint --fix",
"cleanup": "rm -rf logs node_modules bun.lockdb"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.24.0",
"@types/bun": "^1.2.8",
"globals": "^16.0.0"
"@types/ejs": "^3.1.5",
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"eslint": "^9.24.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.15.0",
"prettier": "^3.5.3"
},
"peerDependencies": {
"typescript": "^5.8.3"
},
"dependencies": {
"@atums/echo": "^1.0.3",
"ejs": "^3.1.10",
"node-vibrant": "^4.0.3",
"marked": "^15.0.7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

View file

@ -1,36 +0,0 @@
<svg viewBox="0 0 212 212" xmlns="http://www.w3.org/2000/svg">
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<rdf:RDF>
<cc:Work rdf:about="https://codeberg.org/forgejo/meta/src/branch/readme/branding#logo">
<dc:title>Forgejo logo</dc:title>
<cc:creator rdf:resource="https://caesarschinas.com/"><cc:attributionName>Caesar Schinas</cc:attributionName></cc:creator>
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/"/>
</cc:Work>
</rdf:RDF>
</metadata>
<style type="text/css">
circle {
fill: none;
stroke: #000;
stroke-width: 15;
}
path {
fill: none;
stroke: #000;
stroke-width: 25;
}
.orange {
stroke:#ff6600;
}
.red {
stroke:#d40000;
}
</style>
<g transform="translate(6,6)">
<path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" class="orange"/>
<path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" class="red"/>
<circle cx="142" cy="20" r="18" class="orange"/>
<circle cx="142" cy="88" r="18" class="red"/>
<circle cx="58" cy="180" r="18" class="red"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

25
public/css/error.css Normal file
View file

@ -0,0 +1,25 @@
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 90vh;
background: #0e0e10;
color: #fff;
font-family: system-ui, sans-serif;
}
.error-container {
text-align: center;
padding: 2rem;
background: #1a1a1d;
border-radius: 12px;
box-shadow: 0 0 20px rgba(0,0,0,0.3);
}
.error-title {
font-size: 2rem;
margin-bottom: 1rem;
color: #ff4e4e;
}
.error-message {
font-size: 1.2rem;
opacity: 0.8;
}

View file

@ -1,83 +1,7 @@
.raindrop {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.star,
.snowflake {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.star {
animation: twinkle ease-in-out infinite alternate;
}
.shooting-star {
position: absolute;
background: linear-gradient(90deg, white, transparent);
width: 100px;
height: 2px;
opacity: 0.8;
border-radius: 2px;
transform-origin: left center;
}
@keyframes twinkle {
from {
opacity: 0.3;
transform: scale(1);
}
to {
opacity: 1;
transform: scale(1.2);
}
}
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
transition: opacity 0.5s ease;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid var(--border-color);
border-top: 5px solid var(--progress-fill);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* actual styles below */
body {
font-family: system-ui, sans-serif;
background-color: var(--background);
color: var(--text-color);
background-color: #0e0e10;
color: #ffffff;
margin: 0;
padding: 2rem;
display: flex;
@ -85,59 +9,19 @@ body {
align-items: center;
}
main {
width: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.open-source-logo {
width: 2rem;
height: 2rem;
margin: 0;
padding: 0;
cursor: pointer;
position: fixed;
bottom: 1rem;
right: 0.5rem;
z-index: 1000;
opacity: 0.5;
transition: opacity 0.3s ease;
&:hover {
opacity: 1 !important;
}
}
.hidden {
display: none !important;
}
.activity-header.hidden {
display: none;
}
.user-card {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
max-width: 700px;
max-width: 600px;
width: 100%;
}
.avatar-status-wrapper {
display: flex;
align-items: center;
gap: 2rem;
width: fit-content;
max-width: 700px;
gap: 1.5rem;
}
.avatar-wrapper {
@ -152,35 +36,12 @@ main {
border-radius: 50%;
}
.badges {
max-width: 700px;
box-sizing: border-box;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.badge {
width: 26px;
height: 26px;
border-radius: 50%;
}
.decoration {
position: absolute;
top: -13px;
left: -16px;
width: 160px;
height: 160px;
top: -18px;
left: -18px;
width: 164px;
height: 164px;
pointer-events: none;
}
@ -191,58 +52,35 @@ main {
width: 24px;
height: 24px;
border-radius: 50%;
border: 4px solid var(--background);
border: 4px solid #0e0e10;
display: flex;
align-items: center;
justify-content: center;
}
.status-indicator.online {
background-color: var(--status-online);
background-color: #23a55a;
}
.status-indicator.idle {
background-color: var(--status-idle);
background-color: #f0b232;
}
.status-indicator.dnd {
background-color: var(--status-dnd);
background-color: #f23f43;
}
.status-indicator.offline {
background-color: var(--status-offline);
}
.status-indicator.streaming {
background-color: var(--status-streaming);
background-color: #747f8d;
}
.platform-icon.mobile-only {
position: absolute;
bottom: 0;
bottom: 4px;
right: 4px;
width: 30px;
height: 30px;
pointer-events: none;
background-color: var(--background);
padding: 0.3rem 0.1rem;
border-radius: 8px;
}
.platform-icon.mobile-only.dnd {
fill: var(--status-dnd);
}
.platform-icon.mobile-only.idle {
fill: var(--status-idle);
}
.platform-icon.mobile-only.online {
fill: var(--status-online);
}
.platform-icon.mobile-only.offline {
fill: var(--status-offline);
}
.platform-icon.mobile-only.streaming {
fill: var(--status-streaming);
}
.user-info {
@ -250,65 +88,15 @@ main {
flex-direction: column;
}
.user-info-inner {
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
gap: 0.5rem;
}
.user-info-inner a {
text-decoration: none;
color: var(--link-color);
}
.user-info-inner h1 {
font-size: 2rem;
margin: 0;
}
.clan-badge {
width: fit-content;
height: fit-content;
border-radius: 8px;
background-color: var(--card-bg);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.3rem 0.5rem;
text-align: center;
align-items: center;
justify-content: center;
}
.clan-badge img {
width: 20px;
height: 20px;
margin: 0;
padding: 0;
}
.clan-badge span {
font-size: 0.9rem;
color: var(--text-color);
margin: 0;
font-weight: 600;
}
h1 {
font-size: 2.5rem;
margin: 0;
color: var(--link-color);
color: #00b0f4;
}
.custom-status {
font-size: 1.2rem;
color: var(--text-subtle);
color: #bbb;
margin-top: 0.25rem;
word-break: break-word;
overflow-wrap: anywhere;
@ -319,6 +107,7 @@ h1 {
flex-wrap: wrap;
}
.custom-status .custom-emoji {
width: 20px;
height: 20px;
@ -336,26 +125,7 @@ ul {
list-style: none;
padding: 0;
width: 100%;
max-width: 700px;
}
.activities-section {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 700px;
box-sizing: border-box;
padding: 0;
margin: 0;
}
.activities-section .activity-block-header {
margin: 1rem 0 .5rem;
font-size: 2rem;
font-weight: 600;
text-align: center;
max-width: 600px;
}
.activities {
@ -363,8 +133,7 @@ ul {
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 700px;
box-sizing: border-box;
max-width: 600px;
padding: 0;
margin: 0;
}
@ -373,57 +142,19 @@ ul {
display: flex;
flex-direction: row;
gap: 1rem;
background-color: var(--card-bg);
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid var(--border-color);
transition: background-color 0.3s ease;
&:hover {
background: var(--card-hover-bg);
}
background: #1a1a1d;
padding: 1rem;
border-radius: 6px;
box-shadow: 0 0 0 1px #2e2e30;
transition: background 0.2s ease;
align-items: flex-start;
}
.activity-wrapper {
display: flex;
flex-direction: column;
width: 100%;
.activity:hover {
background: #2a2a2d;
}
.activity-wrapper-inner {
display: flex;
flex-direction: row;
gap: 1rem;
}
.activity-image-wrapper {
position: relative;
width: 80px;
height: 80px;
}
.no-asset {
display: none !important;
}
.activity-image-small {
width: 25px;
height: 25px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border-color: var(--card-bg);
background-color: var(--card-bg);
border-width: 2px;
border-style: solid;
position: absolute;
bottom: -6px;
right: -10px;
}
.activity-image {
.activity-art {
width: 80px;
height: 80px;
border-radius: 6px;
@ -434,15 +165,7 @@ ul {
.activity-content {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
gap: 0.5rem;
position: relative;
}
.activity-top {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@ -452,91 +175,64 @@ ul {
align-items: flex-start;
}
.activity-bottom {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.activity-name {
font-weight: 600;
font-size: 1rem;
color: var(--text-color);
font-weight: bold;
font-size: 1.1rem;
color: #ffffff;
}
.activity-detail {
font-size: 0.875rem;
color: var(--text-secondary);
font-size: 0.95rem;
color: #ccc;
}
.activity-timestamp {
font-size: 0.75rem;
color: var(--text-secondary);
font-size: 0.8rem;
color: #777;
text-align: right;
margin-left: auto;
white-space: nowrap;
}
.progress-bar {
height: 4px;
background-color: var(--border-color);
border-radius: 2px;
margin-top: 1rem;
height: 6px;
background-color: #333;
border-radius: 3px;
overflow: hidden;
width: 100%;
margin-top: 0.5rem;
}
.progress-fill {
background-color: var(--progress-fill);
transition: width 0.4s ease;
height: 100%;
}
.progress-bar,
.progress-time-labels {
width: 100%;
background-color: #00b0f4;
transition: width 0.5s ease;
}
.progress-time-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
color: #888;
margin-top: 0.25rem;
}
.activity-type-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.activity-type-label {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
color: var(--blockquote-color);
margin-bottom: 0.5rem;
display: block;
}
.activity-header.no-timestamp {
justify-content: flex-start;
}
.progress-time-labels.paused .progress-current::after {
content: " ⏸";
color: var(--status-idle);
color: #f0b232;
}
.activity-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
justify-content: flex-end;
}
.activity-button {
background-color: var(--progress-fill);
background-color: #5865f2;
color: white;
border: none;
border-radius: 3px;
@ -546,17 +242,18 @@ ul {
text-decoration: none;
transition: background-color 0.2s ease;
display: inline-block;
}
&:hover {
background-color: var(--button-hover-bg);
text-decoration: none;
}
.activity-button:hover {
background-color: #4752c4;
text-decoration: none;
}
&:disabled {
background-color: var(--button-disabled-bg);
cursor: not-allowed;
opacity: 0.8;
}
.activity-button.disabled {
background-color: #4e5058;
cursor: default;
pointer-events: none;
opacity: 0.8;
}
@media (max-width: 600px) {
@ -573,16 +270,6 @@ ul {
.user-card {
width: 100%;
align-items: center;
margin-top: 2rem;
}
.badges {
max-width: 100%;
border-radius: 0;
border: none;
background-color: transparent;
margin-top: 0;
box-shadow: none;
}
.avatar-status-wrapper {
@ -593,13 +280,6 @@ ul {
width: 100%;
}
.activity-image-wrapper {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
.avatar-wrapper {
width: 96px;
height: 96px;
@ -654,31 +334,21 @@ ul {
align-items: center;
text-align: center;
padding: 1rem;
border-radius: 0;
border-radius:0;
}
.activity-image {
.activity-art {
width: 100%;
max-width: 100%;
max-width: 300px;
height: auto;
border-radius: 8px;
}
.activity-image-small {
width: 40px;
height: 40px;
}
.activity-content {
width: 100%;
align-items: center;
}
.activity-wrapper-inner {
flex-direction: column;
align-items: center;
}
.activity-header {
flex-direction: column;
align-items: center;
@ -715,16 +385,12 @@ ul {
/* readme :p */
.readme {
max-width: fit-content;
min-width: 700px;
overflow: hidden;
max-width: 600px;
width: 100%;
background: var(--readme-bg);
background: #1a1a1d;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
margin-top: 1rem;
box-shadow: 0 0 0 1px #2e2e30;
box-sizing: border-box;
overflow: hidden;
@ -733,13 +399,13 @@ ul {
.readme h2 {
margin-top: 0;
color: var(--link-color);
color: #00b0f4;
}
.markdown-body {
font-size: 1rem;
line-height: 1.6;
color: var(--text-color);
color: #ddd;
}
.markdown-body h1,
@ -748,7 +414,7 @@ ul {
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: var(--text-color);
color: #ffffff;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
@ -758,7 +424,7 @@ ul {
}
.markdown-body a {
color: var(--link-color);
color: #00b0f4;
text-decoration: none;
}
@ -767,7 +433,7 @@ ul {
}
.markdown-body code {
background: var(--border-color);
background: #2e2e30;
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: monospace;
@ -775,7 +441,7 @@ ul {
}
.markdown-body pre {
background: var(--border-color);
background: #2e2e30;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
@ -790,9 +456,9 @@ ul {
}
.markdown-body blockquote {
border-left: 4px solid var(--link-color);
border-left: 4px solid #00b0f4;
padding-left: 1rem;
color: var(--blockquote-color);
color: #aaa;
margin: 1rem 0;
}
@ -802,8 +468,7 @@ ul {
@media (max-width: 600px) {
.readme {
max-width: 100%;
min-width: 100%;
width: 100%;
padding: 1rem;
margin-top: 1rem;
@ -814,218 +479,3 @@ ul {
font-size: 0.95rem;
}
}
/* reviews */
.reviews {
width: 100%;
max-width: 700px;
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
background-color: var(--card-bg);
padding: 1rem;
border-radius: 10px;
border: 1px solid var(--border-color);
box-sizing: border-box;
}
.reviews h2 {
margin: 0 0 1rem;
font-size: 2rem;
font-weight: 600;
text-align: center;
}
.reviews-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.review {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid var(--border-color);
transition: background-color 0.3s ease;
}
.review:hover {
background-color: var(--card-hover-bg);
}
.review-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.review-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.review-header-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.review-username {
font-weight: 600;
color: var(--text-color);
}
.review-timestamp {
font-size: 0.8rem;
color: var(--text-muted);
}
.review-content {
color: var(--text-secondary);
font-size: 0.95rem;
word-break: break-word;
white-space: pre-wrap;
}
.review-badges {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.emoji {
width: 20px;
height: 20px;
vertical-align: middle;
margin: 0 2px;
display: inline-block;
transition: transform 0.3s ease;
}
.emoji:hover {
transform: scale(1.2);
}
.review-content img.emoji {
vertical-align: middle;
}
@media (max-width: 600px) {
.reviews {
max-width: 100%;
padding: 1rem;
border-radius: 0;
border: none;
background-color: transparent;
}
.reviews h2 {
font-size: 1.4rem;
text-align: center;
margin-bottom: 1rem;
}
.reviews-list {
gap: 0.75rem;
}
.review {
flex-direction: column;
align-items: center;
text-align: center;
padding: 1rem;
border-radius: 0;
}
.review-avatar {
width: 64px;
height: 64px;
}
.review-body {
width: 100%;
align-items: center;
}
.review-header {
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.review-username {
font-size: 1rem;
}
.review-timestamp {
font-size: 0.75rem;
}
.review-content {
font-size: 0.9rem;
}
.review-badges {
justify-content: center;
}
.emoji {
width: 16px;
height: 16px;
}
}
/* timezone display */
.timezone-wrapper {
position: fixed;
top: 1rem;
right: 1rem;
background-color: var(--card-bg);
color: var(--text-color);
font-size: 0.9rem;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid var(--border-color);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
z-index: 100;
user-select: none;
opacity: 0.85;
transition: opacity 0.2s ease;
}
.timezone-wrapper:hover {
opacity: 1;
}
.timezone-label {
color: var(--text-muted);
margin-right: 0.4rem;
}
@media (max-width: 600px) {
.timezone-label {
display: none;
}
}

View file

@ -1,29 +0,0 @@
:root {
--background: #0e0e10;
--readme-bg: #1a1a1d;
--card-bg: #1e1f22;
--card-hover-bg: #2a2a2d;
--border-color: #2e2e30;
--text-color: #ffffff;
--text-subtle: #bbb;
--text-secondary: #b5bac1;
--text-muted: #888;
--link-color: #00b0f4;
--button-bg: #5865f2;
--button-hover-bg: #4752c4;
--button-disabled-bg: #2d2e31;
--progress-bg: #f23f43;
--progress-fill: #5865f2;
--status-online: #23a55a;
--status-idle: #f0b232;
--status-dnd: #e03e3e;
--status-offline: #747f8d;
--status-streaming: #b700ff;
--blockquote-color: #aaa;
--code-bg: #2e2e30;
}

File diff suppressed because it is too large Load diff

View file

@ -1,88 +0,0 @@
const rainContainer = document.createElement("div");
rainContainer.style.position = "fixed";
rainContainer.style.top = "0";
rainContainer.style.left = "0";
rainContainer.style.width = "100vw";
rainContainer.style.height = "100vh";
rainContainer.style.pointerEvents = "none";
document.body.appendChild(rainContainer);
const maxRaindrops = 100;
const raindrops = [];
const mouse = { x: -100, y: -100 };
document.addEventListener("mousemove", (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
const getRaindropColor = () => {
const htmlTag = document.documentElement;
return htmlTag.getAttribute("data-theme") === "dark"
? "rgba(173, 216, 230, 0.8)"
: "rgba(70, 130, 180, 0.8)";
};
const createRaindrop = () => {
if (raindrops.length >= maxRaindrops) {
const oldest = raindrops.shift();
rainContainer.removeChild(oldest);
}
const raindrop = document.createElement("div");
raindrop.classList.add("raindrop");
raindrop.style.position = "absolute";
const height = Math.random() * 10 + 10;
raindrop.style.width = "2px";
raindrop.style.height = `${height}px`;
raindrop.style.background = getRaindropColor();
raindrop.style.borderRadius = "1px";
raindrop.style.opacity = Math.random() * 0.5 + 0.3;
raindrop.x = Math.random() * window.innerWidth;
raindrop.y = -height;
raindrop.speed = Math.random() * 6 + 4;
raindrop.directionX = (Math.random() - 0.5) * 0.2;
raindrop.directionY = Math.random() * 0.5 + 0.8;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
raindrops.push(raindrop);
rainContainer.appendChild(raindrop);
};
setInterval(createRaindrop, 50);
function updateRaindrops() {
raindrops.forEach((raindrop, index) => {
const height = Number.parseFloat(raindrop.style.height);
raindrop.x += raindrop.directionX * raindrop.speed;
raindrop.y += raindrop.directionY * raindrop.speed;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
if (raindrop.y > window.innerHeight) {
rainContainer.removeChild(raindrop);
raindrops.splice(index, 1);
return;
}
if (
raindrop.x > window.innerWidth ||
raindrop.y > window.innerHeight ||
raindrop.x < 0
) {
raindrop.x = Math.random() * window.innerWidth;
raindrop.y = -height;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
}
});
requestAnimationFrame(updateRaindrops);
}
updateRaindrops();

View file

@ -1,95 +0,0 @@
const snowContainer = document.createElement("div");
snowContainer.style.position = "fixed";
snowContainer.style.top = "0";
snowContainer.style.left = "0";
snowContainer.style.width = "100vw";
snowContainer.style.height = "100vh";
snowContainer.style.pointerEvents = "none";
document.body.appendChild(snowContainer);
const maxSnowflakes = 60;
const snowflakes = [];
const mouse = { x: -100, y: -100 };
document.addEventListener("mousemove", (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
const createSnowflake = () => {
if (snowflakes.length >= maxSnowflakes) {
const oldestSnowflake = snowflakes.shift();
snowContainer.removeChild(oldestSnowflake);
}
const snowflake = document.createElement("div");
snowflake.classList.add("snowflake");
snowflake.style.position = "absolute";
const size = Math.random() * 3 + 2;
snowflake.style.width = `${size}px`;
snowflake.style.height = `${size}px`;
snowflake.style.background = "white";
snowflake.style.borderRadius = "50%";
snowflake.style.opacity = Math.random();
snowflake.x = Math.random() * window.innerWidth;
snowflake.y = -size;
snowflake.speed = Math.random() * 3 + 2;
snowflake.directionX = (Math.random() - 0.5) * 0.5;
snowflake.directionY = Math.random() * 0.5 + 0.5;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
snowflakes.push(snowflake);
snowContainer.appendChild(snowflake);
};
setInterval(createSnowflake, 80);
function updateSnowflakes() {
snowflakes.forEach((snowflake, index) => {
const size = Number.parseFloat(snowflake.style.width);
const centerX = snowflake.x + size / 2;
const centerY = snowflake.y + size / 2;
const dx = centerX - mouse.x;
const dy = centerY - mouse.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 30) {
snowflake.directionX += (dx / distance) * 0.02;
snowflake.directionY += (dy / distance) * 0.02;
} else {
snowflake.directionX += (Math.random() - 0.5) * 0.01;
snowflake.directionY += (Math.random() - 0.5) * 0.01;
}
snowflake.x += snowflake.directionX * snowflake.speed;
snowflake.y += snowflake.directionY * snowflake.speed;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
if (snowflake.y > window.innerHeight) {
snowContainer.removeChild(snowflake);
snowflakes.splice(index, 1);
return;
}
if (
snowflake.x > window.innerWidth ||
snowflake.y > window.innerHeight ||
snowflake.x < 0
) {
snowflake.x = Math.random() * window.innerWidth;
snowflake.y = -size;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
}
});
requestAnimationFrame(updateSnowflakes);
}
updateSnowflakes();

View file

@ -1,63 +0,0 @@
const container = document.createElement("div");
container.style.position = "fixed";
container.style.top = "0";
container.style.left = "0";
container.style.width = "100vw";
container.style.height = "100vh";
container.style.pointerEvents = "none";
container.style.overflow = "hidden";
container.style.zIndex = "9999";
document.body.appendChild(container);
for (let i = 0; i < 60; i++) {
const star = document.createElement("div");
star.className = "star";
const size = Math.random() * 2 + 1;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
star.style.opacity = Math.random();
star.style.top = `${Math.random() * 100}vh`;
star.style.left = `${Math.random() * 100}vw`;
star.style.animationDuration = `${Math.random() * 3 + 2}s`;
container.appendChild(star);
}
function createShootingStar() {
const star = document.createElement("div");
star.className = "shooting-star";
star.x = Math.random() * window.innerWidth * 0.8;
star.y = Math.random() * window.innerHeight * 0.3;
const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8;
const speed = 10;
const totalFrames = 60;
let frame = 0;
const deg = angle * (180 / Math.PI);
star.style.left = `${star.x}px`;
star.style.top = `${star.y}px`;
star.style.transform = `rotate(${deg}deg)`;
container.appendChild(star);
function animate() {
star.x += Math.cos(angle) * speed;
star.y += Math.sin(angle) * speed;
star.style.left = `${star.x}px`;
star.style.top = `${star.y}px`;
star.style.opacity = `${1 - frame / totalFrames}`;
frame++;
if (frame < totalFrames) {
requestAnimationFrame(animate);
} else if (star.parentNode === container) {
container.removeChild(star);
}
}
animate();
}
setInterval(() => {
if (Math.random() < 0.3) createShootingStar();
}, 1000);

6
src/helpers/char.ts Normal file
View file

@ -0,0 +1,6 @@
export function timestampToReadable(timestamp?: number): string {
const date: Date =
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
if (isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", "");
}

26
src/helpers/ejs.ts Normal file
View file

@ -0,0 +1,26 @@
import { renderFile } from "ejs";
import { resolve } from "path";
export async function renderEjsTemplate(
viewName: string | string[],
data: EjsTemplateData,
headers?: Record<string, string | number | boolean>,
): Promise<Response> {
let templatePath: string;
if (Array.isArray(viewName)) {
templatePath = resolve("src", "views", ...viewName);
} else {
templatePath = resolve("src", "views", viewName);
}
if (!templatePath.endsWith(".ejs")) {
templatePath += ".ejs";
}
const html: string = await renderFile(templatePath, data);
return new Response(html, {
headers: { "Content-Type": "text/html", ...headers },
});
}

92
src/helpers/lanyard.ts Normal file
View file

@ -0,0 +1,92 @@
import { lanyardConfig } from "@config/environment";
import { fetch } from "bun";
import { marked } from "marked";
export async function getLanyardData(id?: string): Promise<LanyardResponse> {
let instance: string = lanyardConfig.instance;
if (instance.endsWith("/")) {
instance = instance.slice(0, -1);
}
if (!instance.startsWith("http://") && !instance.startsWith("https://")) {
instance = `https://${instance}`;
}
const url: string = `${instance}/v1/users/${id || lanyardConfig.userId}`;
const res: Response = await fetch(url, {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
if (!res.ok) {
return {
success: false,
error: {
code: "API_ERROR",
message: `Lanyard API responded with status ${res.status}`,
},
};
}
const data: LanyardResponse = (await res.json()) as LanyardResponse;
if (!data.success) {
return {
success: false,
error: {
code: "API_ERROR",
message: "Failed to fetch valid Lanyard data",
},
};
}
return data;
}
export async function handleReadMe(data: LanyardData): Promise<string | null> {
const userReadMe: string | null = data.kv?.readme;
if (
!userReadMe ||
!userReadMe.toLowerCase().endsWith("readme.md") ||
!userReadMe.startsWith("http")
) {
return null;
}
try {
const res: Response = await fetch(userReadMe, {
headers: {
Accept: "text/markdown",
},
});
const contentType: string = res.headers.get("content-type") || "";
if (
!res.ok ||
!(
contentType.includes("text/markdown") ||
contentType.includes("text/plain")
)
)
return null;
if (res.headers.has("content-length")) {
const size: number = parseInt(
res.headers.get("content-length") || "0",
10,
);
if (size > 1024 * 100) return null;
}
const text: string = await res.text();
if (!text || text.length < 10) return null;
return marked.parse(text);
} catch {
return null;
}
}

205
src/helpers/logger.ts Normal file
View file

@ -0,0 +1,205 @@
import { environment } from "@config/environment";
import { timestampToReadable } from "@helpers/char";
import type { Stats } from "fs";
import {
createWriteStream,
existsSync,
mkdirSync,
statSync,
WriteStream,
} from "fs";
import { EOL } from "os";
import { basename, join } from "path";
class Logger {
private static instance: Logger;
private static log: string = join(__dirname, "../../logs");
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
private writeToLog(logMessage: string): void {
if (environment.development) return;
const date: Date = new Date();
const logDir: string = Logger.log;
const logFile: string = join(
logDir,
`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`,
);
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
let addSeparator: boolean = false;
if (existsSync(logFile)) {
const fileStats: Stats = statSync(logFile);
if (fileStats.size > 0) {
const lastModified: Date = new Date(fileStats.mtime);
if (
lastModified.getFullYear() === date.getFullYear() &&
lastModified.getMonth() === date.getMonth() &&
lastModified.getDate() === date.getDate() &&
lastModified.getHours() !== date.getHours()
) {
addSeparator = true;
}
}
}
const stream: WriteStream = createWriteStream(logFile, { flags: "a" });
if (addSeparator) {
stream.write(`${EOL}${date.toISOString()}${EOL}`);
}
stream.write(`${logMessage}${EOL}`);
stream.close();
}
private extractFileName(stack: string): string {
const stackLines: string[] = stack.split("\n");
let callerFile: string = "";
for (let i: number = 2; i < stackLines.length; i++) {
const line: string = stackLines[i].trim();
if (line && !line.includes("Logger.") && line.includes("(")) {
callerFile = line.split("(")[1]?.split(")")[0] || "";
break;
}
}
return basename(callerFile);
}
private getCallerInfo(stack: unknown): {
filename: string;
timestamp: string;
} {
const filename: string =
typeof stack === "string" ? this.extractFileName(stack) : "unknown";
const readableTimestamp: string = timestampToReadable();
return { filename, timestamp: readableTimestamp };
}
public info(message: string | string[], breakLine: boolean = false): void {
const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack);
const joinedMessage: string = Array.isArray(message)
? message.join(" ")
: message;
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: "[INFO]", color: "32" },
filename: { value: `(${filename})`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public warn(message: string | string[], breakLine: boolean = false): void {
const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack);
const joinedMessage: string = Array.isArray(message)
? message.join(" ")
: message;
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: "[WARN]", color: "33" },
filename: { value: `(${filename})`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public error(
message: string | Error | (string | Error)[],
breakLine: boolean = false,
): void {
const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack);
const messages: (string | Error)[] = Array.isArray(message)
? message
: [message];
const joinedMessage: string = messages
.map((msg: string | Error): string =>
typeof msg === "string" ? msg : msg.message,
)
.join(" ");
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: "[ERROR]", color: "31" },
filename: { value: `(${filename})`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public custom(
bracketMessage: string,
bracketMessage2: string,
message: string | string[],
color: string,
breakLine: boolean = false,
): void {
const stack: string = new Error().stack || "";
const { timestamp } = this.getCallerInfo(stack);
const joinedMessage: string = Array.isArray(message)
? message.join(" ")
: message;
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: bracketMessage, color },
filename: { value: `${bracketMessage2}`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(
`${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`,
);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public space(): void {
console.log();
}
private writeConsoleMessageColored(
logMessageParts: ILogMessageParts,
breakLine: boolean = false,
): void {
const logMessage: string = Object.keys(logMessageParts)
.map((key: string) => {
const part: ILogMessagePart = logMessageParts[key];
return `\x1b[${part.color}m${part.value}\x1b[0m`;
})
.join(" ");
console.log(logMessage + (breakLine ? EOL : ""));
}
}
const logger: Logger = Logger.getInstance();
export { logger };

View file

@ -1,17 +1,16 @@
import { logger } from "@helpers/logger";
import { serverHandler } from "@/server";
import { echo } from "@atums/echo";
import { verifyRequiredVariables } from "@config/environment";
async function main(): Promise<void> {
verifyRequiredVariables();
serverHandler.initialize();
try {
serverHandler.initialize();
} catch (error) {
throw error;
}
}
main().catch((error: Error) => {
echo.error({ message: "Error initializing the server", error });
logger.error(["Error initializing the server:", error]);
process.exit(1);
});
if (process.env.IN_PTERODACTYL === "true") {
console.log("Server Started");
}

View file

@ -1,12 +1,6 @@
import { resolve } from "node:path";
import {
badgeApi,
lanyardConfig,
plausibleScript,
reviewDb,
timezoneAPIUrl,
} from "@config/environment";
import { file } from "bun";
import { lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard";
const routeDef: RouteDef = {
method: "GET",
@ -16,40 +10,44 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> {
const { id } = request.params;
const instance = lanyardConfig.instance
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
const data: LanyardResponse = await getLanyardData(id);
const path = resolve("src", "views", "index.html");
const bunFile = file(path);
if (!data.success) {
return await renderEjsTemplate("error", {
message: data.error.message,
});
}
const html = new HTMLRewriter()
.on("head", {
element(head) {
head.setAttribute("data-user-id", id || lanyardConfig.userId);
head.setAttribute("data-instance-uri", instance);
head.setAttribute("data-badge-url", badgeApi || "");
let instance: string = lanyardConfig.instance;
if (reviewDb.enabled) {
head.setAttribute("data-review-db", reviewDb.url);
}
if (instance.endsWith("/")) {
instance = instance.slice(0, -1);
}
if (timezoneAPIUrl) {
head.setAttribute("data-timezone-api", timezoneAPIUrl);
}
if (instance.startsWith("http://") || instance.startsWith("https://")) {
instance = instance.slice(instance.indexOf("://") + 3);
}
if (plausibleScript) {
head.append(plausibleScript, { html: true });
}
},
})
.transform(await bunFile.text());
const presence: LanyardData = data.data;
const readme: string | Promise<string> | null =
await handleReadMe(presence);
return new Response(html, {
headers: {
"Content-Type": "text/html",
const ejsTemplateData: EjsTemplateData = {
title: `${presence.discord_user.username || "Unknown"}`,
username: presence.discord_user.username,
status: presence.discord_status,
activities: presence.activities,
user: presence.discord_user,
platform: {
desktop: presence.active_on_discord_desktop,
mobile: presence.active_on_discord_mobile,
web: presence.active_on_discord_web,
},
});
instance,
readme,
};
return await renderEjsTemplate("index", ejsTemplateData);
}
export { handler, routeDef };

View file

@ -1,93 +0,0 @@
import { redisTtl, steamGridDbKey } from "@config/environment";
import { redis } from "bun";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
log: false,
};
async function fetchSteamGridIcon(gameName: string): Promise<string | null> {
const cacheKey = `steamgrid:icon:${gameName.toLowerCase()}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
const search = await fetch(
`https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameName)}`,
{
headers: {
Authorization: `Bearer ${steamGridDbKey}`,
},
},
);
if (!search.ok) return null;
const { data } = await search.json();
if (!data?.length) return null;
const gameId = data[0]?.id;
if (!gameId) return null;
const iconRes = await fetch(
`https://www.steamgriddb.com/api/v2/icons/game/${gameId}`,
{
headers: {
Authorization: `Bearer ${steamGridDbKey}`,
},
},
);
if (!iconRes.ok) return null;
const iconData = await iconRes.json();
const icon = iconData?.data?.[0]?.url ?? null;
if (icon) {
await redis.set(cacheKey, icon);
await redis.expire(cacheKey, redisTtl);
}
return icon;
}
async function handler(request: ExtendedRequest): Promise<Response> {
if (!steamGridDbKey) {
return Response.json(
{
status: 503,
error: "Route disabled due to missing SteamGridDB key",
},
{ status: 503 },
);
}
const { game } = request.params;
if (!game || typeof game !== "string" || game.length < 2) {
return Response.json(
{ status: 400, error: "Missing or invalid game name" },
{ status: 400 },
);
}
const icon = await fetchSteamGridIcon(game);
if (!icon) {
return Response.json(
{ status: 404, error: "Icon not found" },
{ status: 404 },
);
}
return Response.json(
{
status: 200,
game,
icon,
},
{ status: 200 },
);
}
export { handler, routeDef };

69
src/routes/api/colors.ts Normal file
View file

@ -0,0 +1,69 @@
import { fetch } from "bun";
import { Vibrant } from "node-vibrant/node";
type Palette = Awaited<ReturnType<typeof Vibrant.prototype.getPalette>>;
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query;
if (!url) {
return Response.json({ error: "URL is required" }, { status: 400 });
}
if (typeof url !== "string" || !url.startsWith("http")) {
return Response.json({ error: "Invalid URL" }, { status: 400 });
}
let res: Response;
try {
res = await fetch(url);
} catch {
return Response.json(
{ error: "Failed to fetch image" },
{ status: 500 },
);
}
if (!res.ok) {
return Response.json(
{ error: "Image fetch returned error" },
{ status: res.status },
);
}
const type: string | null = res.headers.get("content-type");
if (!type?.startsWith("image/")) {
return Response.json({ error: "Not an image" }, { status: 400 });
}
const buffer: Buffer = Buffer.from(await res.arrayBuffer());
const base64: string = buffer.toString("base64");
const colors: Palette = await Vibrant.from(buffer).getPalette();
const payload: {
img: string;
colors: Palette;
} = {
img: `data:${type};base64,${base64}`,
colors,
};
const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(payload));
return new Response(compressed, {
headers: {
"Content-Type": "application/json",
"Content-Encoding": "gzip",
"Cache-Control": "public, max-age=31536000, immutable",
"Access-Control-Allow-Origin": "*",
},
});
}
export { handler, routeDef };

View file

@ -1,85 +0,0 @@
import { redisTtl } from "@config/environment";
import { fetch } from "bun";
import { redis } from "bun";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "*/*",
log: false,
};
async function fetchAndCacheCss(url: string): Promise<string | null> {
const cacheKey = `css:${url}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
const res = await fetch(url, {
headers: {
Accept: "text/css",
},
});
if (!res.ok) return null;
if (res.headers.has("content-length")) {
const size = Number.parseInt(res.headers.get("content-length") || "0", 10);
if (size > 1024 * 50) return null;
}
const text = await res.text();
if (!text || text.length < 5) return null;
const sanitized = text
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/@import\s+url\(['"]?(.*?)['"]?\);?/gi, "");
await redis.set(cacheKey, sanitized);
await redis.expire(cacheKey, redisTtl);
return sanitized;
}
async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query;
if (!url || !url.startsWith("http") || !/\.css$/i.test(url)) {
return Response.json(
{
success: false,
error: {
code: "INVALID_URL",
message: "Invalid URL provided",
},
},
{ status: 400 },
);
}
const sanitized = await fetchAndCacheCss(url);
if (!sanitized) {
return Response.json(
{
success: false,
error: {
code: "FETCH_FAILED",
message: "Failed to fetch or sanitize CSS",
},
},
{ status: 400 },
);
}
return new Response(sanitized, {
headers: {
"Content-Type": "text/css",
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
},
status: 200,
});
}
export { handler, routeDef };

View file

@ -1,139 +0,0 @@
import { redisTtl } from "@config/environment";
import { fetch, redis } from "bun";
import { marked } from "marked";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "*/*",
log: false,
};
async function addLazyLoading(html: string): Promise<string> {
return new HTMLRewriter()
.on("img", {
element(el) {
el.setAttribute("loading", "lazy");
},
})
.transform(html);
}
async function sanitizeHtml(html: string): Promise<string> {
return new HTMLRewriter()
.on(
"script, iframe, object, embed, link[rel=import], svg, math, base, meta[http-equiv='refresh']",
{
element(el) {
el.remove();
},
},
)
.on("*", {
element(el) {
for (const [name, value] of el.attributes) {
const lowerName = name.toLowerCase();
const lowerValue = value.toLowerCase();
if (lowerName.startsWith("on")) {
el.removeAttribute(name);
}
if (
(lowerName === "href" ||
lowerName === "src" ||
lowerName === "action") &&
(lowerValue.startsWith("javascript:") ||
lowerValue.startsWith("data:"))
) {
el.removeAttribute(name);
}
}
},
})
.on("img", {
element(el) {
el.setAttribute("loading", "lazy");
},
})
.transform(html);
}
async function fetchAndCacheReadme(url: string): Promise<string | null> {
const cacheKey = `readme:${url}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
const res = await fetch(url, {
headers: {
Accept: "text/markdown",
},
});
if (!res.ok) return null;
if (res.headers.has("content-length")) {
const size = Number.parseInt(res.headers.get("content-length") || "0", 10);
if (size > 1024 * 100) return null;
}
const text = await res.text();
if (!text || text.length < 10) return null;
const html = /\.(html?|htm)$/i.test(url) ? text : await marked.parse(text);
const safe = await sanitizeHtml(html);
await redis.set(cacheKey, safe);
await redis.expire(cacheKey, redisTtl);
return safe;
}
async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query;
if (
!url ||
!url.startsWith("http") ||
!/\.(md|markdown|txt|html?)$/i.test(url)
) {
return Response.json(
{
success: false,
error: {
code: "INVALID_URL",
message: "Invalid URL provided",
},
},
{ status: 400 },
);
}
const safe = await fetchAndCacheReadme(url);
if (!safe) {
return Response.json(
{
success: false,
error: {
code: "FETCH_FAILED",
message: "Failed to fetch or process file",
},
},
{ status: 400 },
);
}
return new Response(safe, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
},
status: 200,
});
}
export { handler, routeDef };

View file

@ -1,13 +1,54 @@
import { handler as idHandler, routeDef as idRouteDef } from "./[id]";
import { lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard";
export const routeDef = {
...idRouteDef,
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "text/html",
};
export const handler = async (
request: ExtendedRequest,
body: unknown,
server: BunServer,
) => {
return await idHandler(request);
};
async function handler(): Promise<Response> {
const data: LanyardResponse = await getLanyardData();
if (!data.success) {
return await renderEjsTemplate("error", {
message: data.error.message,
});
}
let instance: string = lanyardConfig.instance;
if (instance.endsWith("/")) {
instance = instance.slice(0, -1);
}
if (instance.startsWith("http://") || instance.startsWith("https://")) {
instance = instance.slice(instance.indexOf("://") + 3);
}
const presence: LanyardData = data.data;
const readme: string | Promise<string> | null =
await handleReadMe(presence);
const ejsTemplateData: EjsTemplateData = {
title:
presence.discord_user.global_name || presence.discord_user.username,
username:
presence.discord_user.global_name || presence.discord_user.username,
status: presence.discord_status,
activities: presence.activities,
user: presence.discord_user,
platform: {
desktop: presence.active_on_discord_desktop,
mobile: presence.active_on_discord_mobile,
web: presence.active_on_discord_web,
},
instance,
readme,
};
return await renderEjsTemplate("index", ejsTemplateData);
}
export { handler, routeDef };

View file

@ -1,12 +1,12 @@
import { resolve } from "node:path";
import { echo } from "@atums/echo";
import { environment } from "@config/environment";
import { logger } from "@helpers/logger";
import {
type BunFile,
FileSystemRouter,
type MatchedRoute,
type Serve,
} from "bun";
import { resolve } from "path";
import { webSocketHandler } from "@/websocket";
@ -34,23 +34,29 @@ class ServerHandler {
open: webSocketHandler.handleOpen.bind(webSocketHandler),
message: webSocketHandler.handleMessage.bind(webSocketHandler),
close: webSocketHandler.handleClose.bind(webSocketHandler),
error(error) {
logger.error(`Server error: ${error.message}`);
return new Response(`Server Error: ${error.message}`, {
status: 500,
});
},
},
});
const accessUrls: string[] = [
const accessUrls = [
`http://${server.hostname}:${server.port}`,
`http://localhost:${server.port}`,
`http://127.0.0.1:${server.port}`,
];
echo.info(`Server running at ${accessUrls[0]}`);
echo.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`);
logger.info(`Server running at ${accessUrls[0]}`, true);
logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true);
this.logRoutes();
}
private logRoutes(): void {
echo.info("Available routes:");
logger.info("Available routes:");
const sortedRoutes: [string, string][] = Object.entries(
this.router.routes,
@ -59,19 +65,14 @@ class ServerHandler {
);
for (const [path, filePath] of sortedRoutes) {
echo.info(`Route: ${path}, File: ${filePath}`);
logger.info(`Route: ${path}, File: ${filePath}`);
}
}
private async serveStaticFile(
request: ExtendedRequest,
pathname: string,
ip: string,
): Promise<Response> {
let filePath: string;
let response: Response;
private async serveStaticFile(pathname: string): Promise<Response> {
try {
let filePath: string;
if (pathname === "/favicon.ico") {
filePath = resolve("public", "assets", "favicon.ico");
} else {
@ -82,49 +83,23 @@ class ServerHandler {
if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = file.type || "application/octet-stream";
const contentType: string =
file.type || "application/octet-stream";
response = new Response(fileContent, {
return new Response(fileContent, {
headers: { "Content-Type": contentType },
});
} else {
echo.warn(`File not found: ${filePath}`);
response = new Response("Not Found", { status: 404 });
logger.warn(`File not found: ${filePath}`);
return new Response("Not Found", { status: 404 });
}
} catch (error) {
echo.error({
message: `Error serving static file: ${pathname}`,
error: error as Error,
});
response = new Response("Internal Server Error", { status: 500 });
logger.error([
`Error serving static file: ${pathname}`,
error as Error,
]);
return new Response("Internal Server Error", { status: 500 });
}
this.logRequest(request, response, ip);
return response;
}
private logRequest(
request: ExtendedRequest,
response: Response,
ip: string | undefined,
): void {
const pathname = new URL(request.url).pathname;
const ignoredStartsWith: string[] = ["/public"];
const ignoredPaths: string[] = ["/favicon.ico"];
if (
ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) ||
ignoredPaths.includes(pathname)
) {
return;
}
echo.custom(`${request.method}`, `${response.status}`, [
request.url,
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
ip || "unknown",
]);
}
private async handleRequest(
@ -134,51 +109,22 @@ class ServerHandler {
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
extendedRequest.startPerf = performance.now();
const headers = request.headers;
let ip = server.requestIP(request)?.address;
let response: Response;
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip =
headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
"unknown";
}
const pathname: string = new URL(request.url).pathname;
const baseDir = resolve("public/custom");
const customPath = resolve(baseDir, pathname.slice(1));
if (!customPath.startsWith(baseDir)) {
return new Response("Forbidden", { status: 403 });
}
const customFile = Bun.file(customPath);
if (await customFile.exists()) {
const content = await customFile.arrayBuffer();
const type = customFile.type || "application/octet-stream";
response = new Response(content, {
headers: { "Content-Type": type },
});
this.logRequest(extendedRequest, response, ip);
return response;
}
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
return await this.serveStaticFile(extendedRequest, pathname, ip);
return await this.serveStaticFile(pathname);
}
const match: MatchedRoute | null = this.router.match(request);
let requestBody: unknown = {};
let response: Response;
if (match) {
const { filePath, params, query } = match;
try {
const routeModule: RouteModule = await import(filePath);
const contentType: string | null = request.headers.get("Content-Type");
const contentType: string | null =
request.headers.get("Content-Type");
const actualContentType: string | null = contentType
? contentType.split(";")[0].trim()
: null;
@ -205,7 +151,9 @@ class ServerHandler {
if (
(Array.isArray(routeModule.routeDef.method) &&
!routeModule.routeDef.method.includes(request.method)) ||
!routeModule.routeDef.method.includes(
request.method,
)) ||
(!Array.isArray(routeModule.routeDef.method) &&
routeModule.routeDef.method !== request.method)
) {
@ -230,7 +178,9 @@ class ServerHandler {
if (Array.isArray(expectedContentType)) {
matchesAccepts =
expectedContentType.includes("*/*") ||
expectedContentType.includes(actualContentType || "");
expectedContentType.includes(
actualContentType || "",
);
} else {
matchesAccepts =
expectedContentType === "*/*" ||
@ -269,10 +219,10 @@ class ServerHandler {
}
}
} catch (error: unknown) {
echo.error({
message: `Error handling route ${request.url}`,
error: error,
});
logger.error([
`Error handling route ${request.url}:`,
error as Error,
]);
response = Response.json(
{
@ -294,11 +244,31 @@ class ServerHandler {
);
}
this.logRequest(extendedRequest, response, ip);
const headers: Headers = response.headers;
let ip: string | null = server.requestIP(request)?.address || null;
if (!ip) {
ip =
headers.get("CF-Connecting-IP") ||
headers.get("X-Real-IP") ||
headers.get("X-Forwarded-For") ||
null;
}
logger.custom(
`[${request.method}]`,
`(${response.status})`,
[
request.url,
`${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
);
return response;
}
}
const serverHandler: ServerHandler = new ServerHandler(
environment.port,
environment.host,

17
src/views/error.ejs Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Error</title>
<link rel="stylesheet" href="/public/css/error.css">
<meta name="color-scheme" content="dark">
</head>
<body>
<div class="error-container">
<div class="error-title">Something went wrong</div>
<div class="error-message">
<%= message || "An unexpected error occurred." %>
</div>
</div>
</body>
</html>

150
src/views/index.ejs Normal file
View file

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en">
<head data-user-id="<%= user.id %>" data-instance-uri="<%= instance %>">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="<%= username %>'s Presence">
<meta property="og:image" content="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>">
<meta property="og:description" content="<%= activities[0]?.state || 'Discord Presence' %>">
<title><%= title %></title>
<link rel="stylesheet" href="/public/css/index.css">
<script src="/public/js/index.js" defer></script>
<meta name="color-scheme" content="dark">
</head>
<body>
<div class="user-card">
<div class="avatar-status-wrapper">
<div class="avatar-wrapper">
<img class="avatar" src="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>" alt="Avatar">
<% if (user.avatar_decoration_data) { %>
<img class="decoration" src="https://cdn.discordapp.com/avatar-decoration-presets/<%= user.avatar_decoration_data.asset %>" alt="Decoration">
<% } %>
<% if (platform.mobile) { %>
<svg class="platform-icon mobile-only" viewBox="0 0 1000 1500" fill="#43a25a" aria-label="Mobile" width="17" height="17">
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z"/>
</svg>
<% } else { %>
<div class="status-indicator <%= status %>"></div>
<% } %>
</div>
<div class="user-info">
<h1><%= username %></h1>
<% if (activities.length && activities[0].type === 4) {
const emoji = activities[0].emoji;
const isCustom = emoji?.id;
const emojiUrl = isCustom
? `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`
: null;
%>
<p class="custom-status">
<% if (isCustom && emojiUrl) { %>
<img src="<%= emojiUrl %>" alt="<%= emoji.name %>" class="custom-emoji">
<% } else if (emoji?.name) { %>
<%= emoji.name %>
<% } %>
<%= activities[0].state %>
</p>
<% } %>
</div>
</div>
</div>
<% const filtered = activities.filter(a => a.type !== 4); %>
<% if (filtered.length > 0) { %>
<h2>Activities</h2>
<ul class="activities">
<% filtered.forEach(activity => {
const start = activity.timestamps?.start;
const end = activity.timestamps?.end;
const now = Date.now();
const elapsed = start ? now - start : 0;
const total = (start && end) ? end - start : null;
const progress = (total && elapsed > 0) ? Math.min(100, Math.floor((elapsed / total) * 100)) : null;
const img = activity.assets?.large_image;
let art = null;
if (img?.includes("https")) {
const clean = img.split("/https/")[1];
if (clean) art = `https://${clean}`;
} else if (img?.startsWith("spotify:")) {
art = `https://i.scdn.co/image/${img.split(":")[1]}`;
} else if (img) {
art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`;
}
%>
<li class="activity">
<% if (art) { %>
<img class="activity-art" src="<%= art %>" alt="Art">
<% } %>
<div class="activity-content">
<div class="activity-header <%= progress !== null ? 'no-timestamp' : '' %>">
<span class="activity-name"><%= activity.name %></span>
<% if (start && progress === null) { %>
<div class="activity-timestamp" data-start="<%= start %>">
<% const started = new Date(start); %>
<span>
Since: <%= started.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) %> <span class="elapsed"></span>
</span>
</div>
<% } %>
</div>
<% if (activity.details) { %>
<div class="activity-detail"><%= activity.details %></div>
<% } %>
<% if (activity.state) { %>
<div class="activity-detail"><%= activity.state %></div>
<% } %>
<% if (activity.buttons && activity.buttons.length > 0) { %>
<div class="activity-buttons">
<% activity.buttons.forEach((button, index) => {
const buttonLabel = typeof button === 'string' ? button : button.label;
let buttonUrl = null;
if (typeof button === 'object' && button.url) {
buttonUrl = button.url;
}
else if (index === 0 && activity.url) {
buttonUrl = activity.url;
}
%>
<% if (buttonUrl) { %>
<a href="<%= buttonUrl %>" class="activity-button" target="_blank" rel="noopener noreferrer"><%= buttonLabel %></a>
<% } else { %>
<span class="activity-button disabled"><%= buttonLabel %></span>
<% } %>
<% }); %>
</div>
<% } %>
<% if (progress !== null) { %>
<div class="progress-bar" data-start="<%= start %>" data-end="<%= end %>">
<div class="progress-fill" <%= progress !== null ? `style="width: ${progress}%"` : '' %>></div>
</div>
<% if (start && end) { %>
<div class="progress-time-labels" data-start="<%= start %>" data-end="<%= end %>">
<span class="progress-current">--:--</span>
<span class="progress-total"><%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %></span>
</div>
<% } %>
<% } %>
</div>
</li>
<% }) %>
</ul>
<% } %>
<% if (readme) { %>
<h2>Readme</h2>
<section class="readme">
<div class="markdown-body"><%- readme %></div>
</section>
<% } %>
</body>
</html>

View file

@ -1,73 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description"
content="View real-time Discord presence, activities, and badges with open-source integration." />
<meta name="color-scheme" content="dark" />
<title>Discord Presence</title>
<link rel="icon" id="site-icon" type="image/png" href="/public/assets/favicon.png" />
<link rel="stylesheet" href="/public/css/index.css" />
<link rel="stylesheet" href="/public/css/root.css" />
</head>
<body>
<div id="loading-overlay" role="status" aria-live="polite">
<div class="loading-spinner"></div>
</div>
<header>
<a href="https://git.creations.works/creations/profilePage" target="_blank" rel="noopener noreferrer"
title="View source code on Forgejo">
<img class="open-source-logo" src="/public/assets/forgejo_logo.svg" alt="Forgejo open-source logo"
style="opacity: 0.5" loading="lazy" />
</a>
<div class="timezone-wrapper hidden" id="timezone-wrapper" aria-label="Timezone Information">
<span class="timezone-label">Users Time:</span>
<span class="timezone-value"></span>
</div>
</header>
<main>
<section class="user-card">
<div class="avatar-status-wrapper">
<div class="avatar-wrapper">
<img class="avatar hidden" />
<img class="decoration hidden" />
<div class="status-indicator offline hidden"></div>
</div>
<div class="user-info">
<div class="user-info-inner">
<h1 class="username"></h1>
</div>
</div>
</div>
</section>
<section id="badges" class="badges hidden" aria-label="User Badges"></section>
<section aria-label="Discord Activities" class="activities-section">
<h2 class="activity-block-header hidden">Activities</h2>
<ul class="activities"></ul>
</section>
<section class="readme hidden" aria-label="Profile README">
<div class="markdown-body"></div>
</section>
</main>
<section class="reviews hidden" aria-label="User Reviews" id="reviews-section">
<h2>User Reviews</h2>
<ul class="reviews-list">
</ul>
</section>
<script src="/public/js/index.js" type="module"></script>
</body>
</html>

View file

@ -1,27 +1,31 @@
import { echo } from "@atums/echo";
import type { ServerWebSocket } from "bun";
import { logger } from "@helpers/logger";
import { type ServerWebSocket } from "bun";
class WebSocketHandler {
public handleMessage(ws: ServerWebSocket, message: string): void {
echo.info(`WebSocket received: ${message}`);
logger.info(`WebSocket received: ${message}`);
try {
ws.send(`You said: ${message}`);
} catch (error) {
echo.error({ message: "WebSocket send error", error: error });
logger.error(["WebSocket send error", error as Error]);
}
}
public handleOpen(ws: ServerWebSocket): void {
echo.info("WebSocket connection opened.");
logger.info("WebSocket connection opened.");
try {
ws.send("Welcome to the WebSocket server!");
} catch (error) {
echo.error({ message: "WebSocket send error", error: error });
logger.error(["WebSocket send error", error as Error]);
}
}
public handleClose(ws: ServerWebSocket, code: number, reason: string): void {
echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
public handleClose(
ws: ServerWebSocket,
code: number,
reason: string,
): void {
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
}
}

View file

@ -2,14 +2,28 @@
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@config/*": ["config/*"],
"@types/*": ["types/*"],
"@helpers/*": ["src/helpers/*"]
"@/*": [
"src/*"
],
"@config/*": [
"config/*"
],
"@types/*": [
"types/*"
],
"@helpers/*": [
"src/helpers/*"
]
},
"typeRoots": ["./src/types", "./node_modules/@types"],
"typeRoots": [
"./src/types",
"./node_modules/@types"
],
// Enable latest features
"lib": ["ESNext", "DOM"],
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
@ -27,7 +41,11 @@
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
"noPropertyAccessFromIndexSignature": false,
},
"include": ["src", "types", "config"]
"include": [
"src",
"types",
"config"
],
}

3
types/ejs.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
interface EjsTemplateData {
[key: string]: string | number | boolean | object | undefined | null;
}

72
types/lanyard.d.ts vendored Normal file
View file

@ -0,0 +1,72 @@
interface DiscordUser {
id: string;
username: string;
avatar: string;
discriminator: string;
clan?: string | null;
avatar_decoration_data?: {
sku_id: string;
asset: string;
expires_at: string | null;
};
bot: boolean;
global_name: string;
primary_guild?: string | null;
collectibles?: {
enabled: boolean;
disabled: boolean;
};
display_name: string;
public_flags: number;
}
interface Activity {
id: string;
name: string;
type: number;
state: string;
created_at: number;
}
interface SpotifyData {
track_id: string;
album_id: string;
album_name: string;
artist_name: string;
track_name: string;
}
interface Kv {
[key: string]: string;
}
interface LanyardData {
kv: Kv;
discord_user: DiscordUser;
activities: Activity[];
discord_status: string;
active_on_discord_web: boolean;
active_on_discord_desktop: boolean;
active_on_discord_mobile: boolean;
listening_to_spotify?: boolean;
spotify?: SpotifyData;
spotify_status: string;
active_on_spotify: boolean;
active_on_xbox: boolean;
active_on_playstation: boolean;
}
type LanyardSuccess = {
success: true;
data: LanyardData;
};
type LanyardError = {
success: false;
error: {
code: string;
message: string;
};
};
type LanyardResponse = LanyardSuccess | LanyardError;

9
types/logger.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
type ILogMessagePart = { value: string; color: string };
type ILogMessageParts = {
level: ILogMessagePart;
filename: ILogMessagePart;
readableTimestamp: ILogMessagePart;
message: ILogMessagePart;
[key: string]: ILogMessagePart;
};

1
types/routes.d.ts vendored
View file

@ -3,7 +3,6 @@ type RouteDef = {
accepts: string | null | string[];
returns: string;
needsBody?: "multipart" | "json";
log?: boolean;
};
type RouteModule = {