commit b11c87a5061baf61ad89d4830fbe35157e954280 Author: creations Date: Sat Apr 5 01:28:29 2025 -0400 first commit 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 0000000..69ec50d Binary files /dev/null and b/public/assets/favicon.ico differ diff --git a/public/css/error.css b/public/css/error.css new file mode 100644 index 0000000..a8e591d --- /dev/null +++ b/public/css/error.css @@ -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; +} diff --git a/public/css/index.css b/public/css/index.css new file mode 100644 index 0000000..d6b1db9 --- /dev/null +++ b/public/css/index.css @@ -0,0 +1,335 @@ +body { + font-family: system-ui, sans-serif; + background-color: #0e0e10; + color: #ffffff; + margin: 0; + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; +} + +.user-card { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 2rem; + max-width: 600px; + width: 100%; +} + +.avatar-status-wrapper { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.avatar-wrapper { + position: relative; + width: 128px; + height: 128px; +} + +.avatar { + width: 128px; + height: 128px; + border-radius: 50%; +} + +.decoration { + position: absolute; + top: -18px; + left: -18px; + width: 164px; + height: 164px; + pointer-events: none; +} + +.status-indicator { + position: absolute; + bottom: 4px; + right: 4px; + width: 24px; + height: 24px; + border-radius: 50%; + border: 4px solid #0e0e10; + display: flex; + align-items: center; + justify-content: center; +} + +.status-indicator.online { + background-color: #23a55a; +} + +.status-indicator.idle { + background-color: #f0b232; +} + +.status-indicator.dnd { + background-color: #f23f43; +} + +.status-indicator.offline { + background-color: #747f8d; +} + +.platform-icon.mobile-only { + position: absolute; + bottom: 4px; + right: 4px; + width: 30px; + height: 30px; + pointer-events: none; +} + +.user-info { + display: flex; + flex-direction: column; +} + +h1 { + font-size: 2.5rem; + margin: 0; + color: #00b0f4; +} + +.custom-status { + font-size: 1.2rem; + color: #bbb; + margin-top: 0.25rem; + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + + +.custom-status .custom-emoji { + width: 20px; + height: 20px; + vertical-align: text-bottom; + margin-right: 4px; + display: inline-block; +} + +h2 { + font-size: 1.8rem; + margin: 2rem 0 1rem; +} + +ul { + list-style: none; + padding: 0; + width: 100%; + max-width: 600px; +} + +.activities { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + max-width: 600px; + padding: 0; + margin: 0; +} + +.activity { + display: flex; + flex-direction: row; + gap: 1rem; + background: #1a1a1d; + padding: 1rem; + border-radius: 6px; + box-shadow: 0 0 0 1px #2e2e30; + transition: background 0.2s ease; + align-items: flex-start; +} + +.activity:hover { + background: #2a2a2d; +} + +.activity-art { + width: 80px; + height: 80px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; +} + +.activity-content { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.25rem; +} + +.activity-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.activity-name { + font-weight: bold; + font-size: 1.1rem; + color: #ffffff; +} + +.activity-detail { + font-size: 0.95rem; + color: #ccc; +} + +.activity-timestamp { + font-size: 0.8rem; + color: #777; + text-align: right; +} + +.progress-bar { + height: 6px; + background-color: #333; + border-radius: 3px; + overflow: hidden; + width: 100%; + margin-top: 0.5rem; +} + +.progress-fill { + height: 100%; + background-color: #00b0f4; + transition: width 0.5s ease; +} + +.progress-time-labels { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: #888; + margin-top: 0.25rem; +} + +.activity-header.no-timestamp { + justify-content: flex-start; +} + +.progress-time-labels.paused .progress-current::after { + content: " ⏸"; + color: #f0b232; +} + +@media (max-width: 600px) { + html { + font-size: clamp(14px, 2vw, 16px); + } + + body { + padding: 1rem; + align-items: stretch; + } + + .user-card { + width: 100%; + align-items: center; + } + + .avatar-status-wrapper { + flex-direction: column; + align-items: center; + gap: 1rem; + text-align: center; + width: 100%; + } + + .avatar-wrapper { + width: 96px; + height: 96px; + } + + .avatar { + width: 96px; + height: 96px; + } + + .decoration { + width: 128px; + height: 128px; + top: -16px; + left: -16px; + } + + .status-indicator, + .platform-icon.mobile-only { + width: 20px; + height: 20px; + bottom: 2px; + right: 2px; + } + + .user-info { + align-items: center; + text-align: center; + } + + h1 { + font-size: 2rem; + } + + .custom-status { + font-size: 1rem; + flex-direction: column; + gap: 0.2rem; + } + + h2 { + font-size: 1.4rem; + text-align: center; + } + + .activities { + gap: 0.75rem; + } + + .activity { + flex-direction: column; + align-items: center; + text-align: center; + padding: 1rem; + } + + .activity-art { + width: 100%; + max-width: 300px; + height: auto; + border-radius: 8px; + } + + .activity-content { + width: 100%; + align-items: center; + } + + .activity-header { + flex-direction: column; + align-items: center; + gap: 0.25rem; + } + + .activity-timestamp { + text-align: center; + font-size: 0.75rem; + } + + .activity-detail { + text-align: center; + } +} + + + diff --git a/public/js/index.js b/public/js/index.js new file mode 100644 index 0000000..60b6c1a --- /dev/null +++ b/public/js/index.js @@ -0,0 +1,232 @@ +/* eslint-disable indent */ + +const activityProgressMap = new Map(); + +function formatTime(ms) { + const totalSecs = Math.floor(ms / 1000); + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +function updateElapsedAndProgress() { + const now = Date.now(); + + document.querySelectorAll(".activity-timestamp").forEach((el) => { + 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; +};