From b11c87a5061baf61ad89d4830fbe35157e954280 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 5 Apr 2025 01:28:29 -0400 Subject: [PATCH] first commit --- README.md | 3 + config/environment.ts | 12 ++ eslint.config.js | 132 +++++++++++++++ package.json | 33 ++++ public/assets/favicon.ico | Bin 0 -> 15406 bytes public/css/error.css | 25 +++ public/css/index.css | 335 ++++++++++++++++++++++++++++++++++++++ public/js/index.js | 232 ++++++++++++++++++++++++++ src/helpers/char.ts | 6 + src/helpers/ejs.ts | 26 +++ src/helpers/lanyard.ts | 46 ++++++ src/helpers/logger.ts | 205 +++++++++++++++++++++++ src/index.ts | 16 ++ src/routes/[id].ts | 51 ++++++ src/routes/index.ts | 50 ++++++ src/server.ts | 267 ++++++++++++++++++++++++++++++ src/views/error.ejs | 17 ++ src/views/index.ejs | 118 ++++++++++++++ src/websocket.ts | 34 ++++ tsconfig.json | 51 ++++++ types/bun.d.ts | 14 ++ types/config.d.ts | 10 ++ types/ejs.d.ts | 3 + types/lanyard.d.ts | 72 ++++++++ types/logger.d.ts | 9 + types/routes.d.ts | 15 ++ 26 files changed, 1782 insertions(+) create mode 100644 README.md create mode 100644 config/environment.ts create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 public/assets/favicon.ico create mode 100644 public/css/error.css create mode 100644 public/css/index.css create mode 100644 public/js/index.js create mode 100644 src/helpers/char.ts create mode 100644 src/helpers/ejs.ts create mode 100644 src/helpers/lanyard.ts create mode 100644 src/helpers/logger.ts create mode 100644 src/index.ts create mode 100644 src/routes/[id].ts create mode 100644 src/routes/index.ts create mode 100644 src/server.ts create mode 100644 src/views/error.ejs create mode 100644 src/views/index.ejs create mode 100644 src/websocket.ts create mode 100644 tsconfig.json create mode 100644 types/bun.d.ts create mode 100644 types/config.d.ts create mode 100644 types/ejs.d.ts create mode 100644 types/lanyard.d.ts create mode 100644 types/logger.d.ts create mode 100644 types/routes.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..48d4c50 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Cool little discord profile page + +E diff --git a/config/environment.ts b/config/environment.ts new file mode 100644 index 0000000..c584b45 --- /dev/null +++ b/config/environment.ts @@ -0,0 +1,12 @@ +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"), +}; + +export const lanyardConfig: LanyardConfig = { + userId: process.env.LANYARD_USER_ID || "", + instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d43df76 --- /dev/null +++ b/eslint.config.js @@ -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, + }, + ], + }, + }, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..8240d4f --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "bun_frontend_template", + "module": "src/index.ts", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun run --hot src/index.ts --dev", + "lint": "eslint", + "lint:fix": "bun lint --fix", + "cleanup": "rm -rf logs node_modules bun.lockdb" + }, + "devDependencies": { + "@eslint/js": "^9.23.0", + "@types/bun": "^1.2.6", + "@types/ejs": "^3.1.5", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", + "eslint": "^9.23.0", + "eslint-plugin-prettier": "^5.2.5", + "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.2" + }, + "dependencies": { + "ejs": "^3.1.10" + } +} diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..69ec50db0ae55e9b54fd9fbf88c34bb1c420fb63 GIT binary patch literal 15406 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 { + const start = Number(el.dataset.start); + if (!start) return; + + const elapsed = now - start; + const mins = Math.floor(elapsed / 60000); + const secs = Math.floor((elapsed % 60000) / 1000); + const display = el.querySelector(".elapsed"); + if (display) + display.textContent = `(${mins}m ${secs.toString().padStart(2, "0")}s ago)`; + }); + + document.querySelectorAll(".progress-bar").forEach((bar) => { + const start = Number(bar.dataset.start); + const end = Number(bar.dataset.end); + if (!start || !end || end <= start) return; + + const duration = end - start; + const elapsed = now - start; + const progress = Math.min( + 100, + Math.max(0, Math.floor((elapsed / duration) * 100)), + ); + + const fill = bar.querySelector(".progress-fill"); + if (fill) fill.style.width = `${progress}%`; + }); + + document.querySelectorAll(".progress-time-labels").forEach((label) => { + const start = Number(label.dataset.start); + const end = Number(label.dataset.end); + if (!start || !end || end <= start) return; + + const current = Math.max(0, now - start); + const total = end - start; + + const currentEl = label.querySelector(".progress-current"); + const totalEl = label.querySelector(".progress-total"); + + const id = `${start}-${end}`; + const last = activityProgressMap.get(id); + + if (last !== undefined && last === current) { + label.classList.add("paused"); + } else { + label.classList.remove("paused"); + } + + activityProgressMap.set(id, current); + + if (currentEl) currentEl.textContent = formatTime(current); + if (totalEl) totalEl.textContent = formatTime(total); + }); +} + +updateElapsedAndProgress(); +setInterval(updateElapsedAndProgress, 1000); + +const head = document.querySelector("head"); +let userId = head?.dataset.userId; +let instanceUri = head?.dataset.instanceUri; + +console.log("User ID:", userId); +console.log("Instance URI:", instanceUri); + +if (userId && instanceUri) { + if (!instanceUri.startsWith("http")) { + instanceUri = `https://${instanceUri}`; + } + + const wsUri = instanceUri + .replace(/^http:/, "ws:") + .replace(/^https:/, "wss:") + .replace(/\/$/, ""); + + const socket = new WebSocket(`${wsUri}/socket`); + + socket.addEventListener("open", () => { + socket.send( + JSON.stringify({ + op: 2, + d: { + subscribe_to_id: userId, + }, + }), + ); + }); + + socket.addEventListener("message", (event) => { + const payload = JSON.parse(event.data); + + if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { + updatePresence(payload.d); + updateElapsedAndProgress(); + } + }); +} + +function buildActivityHTML(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`; + } + + const activityTimestamp = + !total && start + ? ` +
+ + Since: ${new Date(start).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + +
` + : ""; + + const progressBar = + progress !== null + ? ` +
+
+
+
+ ${formatTime(elapsed)} + ${formatTime(total)} +
+ ` + : ""; + + return ` +
  • + ${art ? `Art` : ""} +
    +
    + ${activity.name} + ${activityTimestamp} +
    + ${activity.details ? `
    ${activity.details}
    ` : ""} + ${activity.state ? `
    ${activity.state}
    ` : ""} + ${progressBar} +
    +
  • + `; +} + +function updatePresence(data) { + const avatarWrapper = document.querySelector(".avatar-wrapper"); + const statusIndicator = avatarWrapper?.querySelector(".status-indicator"); + const mobileIcon = avatarWrapper?.querySelector( + ".platform-icon.mobile-only", + ); + + const userInfo = document.querySelector(".user-info"); + const customStatus = userInfo?.querySelector(".custom-status"); + + const platform = { + mobile: data.active_on_discord_mobile, + web: data.active_on_discord_web, + desktop: data.active_on_discord_desktop, + }; + + if (statusIndicator) { + statusIndicator.className = `status-indicator ${data.discord_status}`; + } + + if (platform.mobile && !mobileIcon) { + avatarWrapper.innerHTML += ` + + + + `; + } else if (!platform.mobile && mobileIcon) { + mobileIcon.remove(); + avatarWrapper.innerHTML += `
    `; + } + + const custom = data.activities?.find((a) => a.type === 4); + if (customStatus && custom) { + let emojiHTML = ""; + const emoji = custom.emoji; + if (emoji?.id) { + const emojiUrl = `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`; + emojiHTML = `${emoji.name}`; + } else if (emoji?.name) { + emojiHTML = `${emoji.name} `; + } + customStatus.innerHTML = `${emojiHTML}${custom.state}`; + } + + const filtered = data.activities?.filter((a) => a.type !== 4); + const activityList = document.querySelector(".activities"); + + if (activityList) { + activityList.innerHTML = ""; + if (filtered?.length) { + activityList.innerHTML = filtered.map(buildActivityHTML).join(""); + } + updateElapsedAndProgress(); + } +} diff --git a/src/helpers/char.ts b/src/helpers/char.ts new file mode 100644 index 0000000..6ecab40 --- /dev/null +++ b/src/helpers/char.ts @@ -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", ""); +} diff --git a/src/helpers/ejs.ts b/src/helpers/ejs.ts new file mode 100644 index 0000000..6b03dd0 --- /dev/null +++ b/src/helpers/ejs.ts @@ -0,0 +1,26 @@ +import { renderFile } from "ejs"; +import { resolve } from "path"; + +export async function renderEjsTemplate( + viewName: string | string[], + data: EjsTemplateData, + headers?: Record, +): Promise { + 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 }, + }); +} diff --git a/src/helpers/lanyard.ts b/src/helpers/lanyard.ts new file mode 100644 index 0000000..6592d7b --- /dev/null +++ b/src/helpers/lanyard.ts @@ -0,0 +1,46 @@ +import { lanyardConfig } from "@config/environment"; +import { fetch } from "bun"; + +export async function getLanyardData(id?: string): Promise { + 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; +} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts new file mode 100644 index 0000000..331be1d --- /dev/null +++ b/src/helpers/logger.ts @@ -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 }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6d2801d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,16 @@ +import { logger } from "@helpers/logger"; + +import { serverHandler } from "@/server"; + +async function main(): Promise { + try { + serverHandler.initialize(); + } catch (error) { + throw error; + } +} + +main().catch((error: Error) => { + logger.error(["Error initializing the server:", error]); + process.exit(1); +}); diff --git a/src/routes/[id].ts b/src/routes/[id].ts new file mode 100644 index 0000000..e43c73d --- /dev/null +++ b/src/routes/[id].ts @@ -0,0 +1,51 @@ +import { lanyardConfig } from "@config/environment"; +import { renderEjsTemplate } from "@helpers/ejs"; +import { getLanyardData } from "@helpers/lanyard"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "text/html", +}; + +async function handler(request: ExtendedRequest): Promise { + const { id } = request.params; + const data: LanyardResponse = await getLanyardData(id); + + if (!data.success) { + return await renderEjsTemplate("error", { + message: "User not found or Lanyard data unavailable.", + }); + } + + 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 ejsTemplateData: EjsTemplateData = { + title: "User Page", + 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: instance, + }; + + return await renderEjsTemplate("index", ejsTemplateData); +} + +export { handler, routeDef }; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..d9da07b --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,50 @@ +import { lanyardConfig } from "@config/environment"; +import { renderEjsTemplate } from "@helpers/ejs"; +import { getLanyardData } from "@helpers/lanyard"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "text/html", +}; + +async function handler(): Promise { + const data: LanyardResponse = await getLanyardData(); + + if (!data.success) { + return Response.json(data.error, { + status: 500, + }); + } + + 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 ejsTemplateData: EjsTemplateData = { + title: "User Page", + 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: instance, + }; + + return await renderEjsTemplate("index", ejsTemplateData); +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..3f78cb5 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,267 @@ +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"; + +class ServerHandler { + private router: FileSystemRouter; + + constructor( + private port: number, + private host: string, + ) { + this.router = new FileSystemRouter({ + style: "nextjs", + dir: "./src/routes", + fileExtensions: [".ts"], + origin: `http://${this.host}:${this.port}`, + }); + } + + public initialize(): void { + const server: Serve = Bun.serve({ + port: this.port, + hostname: this.host, + fetch: this.handleRequest.bind(this), + websocket: { + open: webSocketHandler.handleOpen.bind(webSocketHandler), + message: webSocketHandler.handleMessage.bind(webSocketHandler), + close: webSocketHandler.handleClose.bind(webSocketHandler), + }, + }); + + logger.info( + `Server running at http://${server.hostname}:${server.port}`, + true, + ); + + this.logRoutes(); + } + + private logRoutes(): void { + logger.info("Available routes:"); + + const sortedRoutes: [string, string][] = Object.entries( + this.router.routes, + ).sort(([pathA]: [string, string], [pathB]: [string, string]) => + pathA.localeCompare(pathB), + ); + + for (const [path, filePath] of sortedRoutes) { + logger.info(`Route: ${path}, File: ${filePath}`); + } + } + + private async serveStaticFile(pathname: string): Promise { + try { + let filePath: string; + + if (pathname === "/favicon.ico") { + filePath = resolve("public", "assets", "favicon.ico"); + } else { + filePath = resolve(`.${pathname}`); + } + + const file: BunFile = Bun.file(filePath); + + if (await file.exists()) { + const fileContent: ArrayBuffer = await file.arrayBuffer(); + const contentType: string = + file.type || "application/octet-stream"; + + return new Response(fileContent, { + headers: { "Content-Type": contentType }, + }); + } else { + logger.warn(`File not found: ${filePath}`); + return new Response("Not Found", { status: 404 }); + } + } catch (error) { + logger.error([ + `Error serving static file: ${pathname}`, + error as Error, + ]); + return new Response("Internal Server Error", { status: 500 }); + } + } + + private async handleRequest( + request: Request, + server: BunServer, + ): Promise { + const extendedRequest: ExtendedRequest = request as ExtendedRequest; + extendedRequest.startPerf = performance.now(); + + const pathname: string = new URL(request.url).pathname; + if (pathname.startsWith("/public") || pathname === "/favicon.ico") { + 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 actualContentType: string | null = contentType + ? contentType.split(";")[0].trim() + : null; + + if ( + routeModule.routeDef.needsBody === "json" && + actualContentType === "application/json" + ) { + try { + requestBody = await request.json(); + } catch { + requestBody = {}; + } + } else if ( + routeModule.routeDef.needsBody === "multipart" && + actualContentType === "multipart/form-data" + ) { + try { + requestBody = await request.formData(); + } catch { + requestBody = {}; + } + } + + if ( + (Array.isArray(routeModule.routeDef.method) && + !routeModule.routeDef.method.includes( + request.method, + )) || + (!Array.isArray(routeModule.routeDef.method) && + routeModule.routeDef.method !== request.method) + ) { + response = Response.json( + { + success: false, + code: 405, + error: `Method ${request.method} Not Allowed, expected ${ + Array.isArray(routeModule.routeDef.method) + ? routeModule.routeDef.method.join(", ") + : routeModule.routeDef.method + }`, + }, + { status: 405 }, + ); + } else { + const expectedContentType: string | string[] | null = + routeModule.routeDef.accepts; + + let matchesAccepts: boolean; + + if (Array.isArray(expectedContentType)) { + matchesAccepts = + expectedContentType.includes("*/*") || + expectedContentType.includes( + actualContentType || "", + ); + } else { + matchesAccepts = + expectedContentType === "*/*" || + actualContentType === expectedContentType; + } + + if (!matchesAccepts) { + response = Response.json( + { + success: false, + code: 406, + error: `Content-Type ${actualContentType} Not Acceptable, expected ${ + Array.isArray(expectedContentType) + ? expectedContentType.join(", ") + : expectedContentType + }`, + }, + { status: 406 }, + ); + } else { + extendedRequest.params = params; + extendedRequest.query = query; + + response = await routeModule.handler( + extendedRequest, + requestBody, + server, + ); + + if (routeModule.routeDef.returns !== "*/*") { + response.headers.set( + "Content-Type", + routeModule.routeDef.returns, + ); + } + } + } + } catch (error: unknown) { + logger.error([ + `Error handling route ${request.url}:`, + error as Error, + ]); + + response = Response.json( + { + success: false, + code: 500, + error: "Internal Server Error", + }, + { status: 500 }, + ); + } + } else { + response = Response.json( + { + success: false, + code: 404, + error: "Not Found", + }, + { status: 404 }, + ); + } + + 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, +); + +export { serverHandler }; diff --git a/src/views/error.ejs b/src/views/error.ejs new file mode 100644 index 0000000..1a683f1 --- /dev/null +++ b/src/views/error.ejs @@ -0,0 +1,17 @@ + + + + + Error + + + + +
    +
    Something went wrong
    +
    + <%= message || "An unexpected error occurred." %> +
    +
    + + diff --git a/src/views/index.ejs b/src/views/index.ejs new file mode 100644 index 0000000..e95c0ff --- /dev/null +++ b/src/views/index.ejs @@ -0,0 +1,118 @@ + + + + + + <%= title %> + + + + + + + +
    +
    +
    + Avatar + <% if (user.avatar_decoration_data) { %> + Decoration + <% } %> + <% if (platform.mobile) { %> + + + + <% } else { %> +
    + <% } %> +
    + +
    +
    + + <% const filtered = activities.filter(a => a.type !== 4); %> + <% if (filtered.length > 0) { %> +

    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`; + } + %> +
    • + <% if (art) { %> + Art + <% } %> + +
      +
      + <%= activity.name %> + + <% if (start && progress === null) { %> +
      + <% const started = new Date(start); %> + + Since: <%= started.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) %> + +
      + <% } %> +
      + + <% if (activity.details) { %> +
      <%= activity.details %>
      + <% } %> + <% if (activity.state) { %> +
      <%= activity.state %>
      + <% } %> + + <% if (progress !== null) { %> +
      +
      +
      + + <% if (start && end) { %> +
      + --:-- + <%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %> +
      + <% } %> + <% } %> +
      +
    • + <% }) %> +
    + <% } %> + + diff --git a/src/websocket.ts b/src/websocket.ts new file mode 100644 index 0000000..ce87fe8 --- /dev/null +++ b/src/websocket.ts @@ -0,0 +1,34 @@ +import { logger } from "@helpers/logger"; +import { type ServerWebSocket } from "bun"; + +class WebSocketHandler { + public handleMessage(ws: ServerWebSocket, message: string): void { + logger.info(`WebSocket received: ${message}`); + try { + ws.send(`You said: ${message}`); + } catch (error) { + logger.error(["WebSocket send error", error as Error]); + } + } + + public handleOpen(ws: ServerWebSocket): void { + logger.info("WebSocket connection opened."); + try { + ws.send("Welcome to the WebSocket server!"); + } catch (error) { + logger.error(["WebSocket send error", error as Error]); + } + } + + public handleClose( + ws: ServerWebSocket, + code: number, + reason: string, + ): void { + logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + } +} + +const webSocketHandler: WebSocketHandler = new WebSocketHandler(); + +export { webSocketHandler }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ac5f2c7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,51 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": [ + "src/*" + ], + "@config/*": [ + "config/*" + ], + "@types/*": [ + "types/*" + ], + "@helpers/*": [ + "src/helpers/*" + ] + }, + "typeRoots": [ + "./src/types", + "./node_modules/@types" + ], + // Enable latest features + "lib": [ + "ESNext", + "DOM" + ], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + }, + "include": [ + "src", + "types", + "config" + ], +} diff --git a/types/bun.d.ts b/types/bun.d.ts new file mode 100644 index 0000000..018bf35 --- /dev/null +++ b/types/bun.d.ts @@ -0,0 +1,14 @@ +import type { Server } from "bun"; + +type Query = Record; +type Params = Record; + +declare global { + type BunServer = Server; + + interface ExtendedRequest extends Request { + startPerf: number; + query: Query; + params: Params; + } +} diff --git a/types/config.d.ts b/types/config.d.ts new file mode 100644 index 0000000..c75c834 --- /dev/null +++ b/types/config.d.ts @@ -0,0 +1,10 @@ +type Environment = { + port: number; + host: string; + development: boolean; +}; + +type LanyardConfig = { + userId: string; + instance: string; +}; diff --git a/types/ejs.d.ts b/types/ejs.d.ts new file mode 100644 index 0000000..486a4a4 --- /dev/null +++ b/types/ejs.d.ts @@ -0,0 +1,3 @@ +interface EjsTemplateData { + [key: string]: string | number | boolean | object | undefined | null; +} diff --git a/types/lanyard.d.ts b/types/lanyard.d.ts new file mode 100644 index 0000000..9c0e136 --- /dev/null +++ b/types/lanyard.d.ts @@ -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; diff --git a/types/logger.d.ts b/types/logger.d.ts new file mode 100644 index 0000000..ff6a601 --- /dev/null +++ b/types/logger.d.ts @@ -0,0 +1,9 @@ +type ILogMessagePart = { value: string; color: string }; + +type ILogMessageParts = { + level: ILogMessagePart; + filename: ILogMessagePart; + readableTimestamp: ILogMessagePart; + message: ILogMessagePart; + [key: string]: ILogMessagePart; +}; diff --git a/types/routes.d.ts b/types/routes.d.ts new file mode 100644 index 0000000..9d9d809 --- /dev/null +++ b/types/routes.d.ts @@ -0,0 +1,15 @@ +type RouteDef = { + method: string | string[]; + accepts: string | null | string[]; + returns: string; + needsBody?: "multipart" | "json"; +}; + +type RouteModule = { + handler: ( + request: Request | ExtendedRequest, + requestBody: unknown, + server: BunServer, + ) => Promise | Response; + routeDef: RouteDef; +};