diff --git a/.env.example b/.env.example index 626a366..a10f7a4 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,8 @@ 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 diff --git a/.gitignore b/.gitignore index 5030290..16498de 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ bun.lock logs/ .vscode/ robots.txt +public/custom diff --git a/README.md b/README.md index 3b0cf84..b4a85fd 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ cp .env.example .env | `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) | | `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons | -| `ROBOTS_FILE` | If there it uses the file in /robots.txt route, requires a valid path | #### Optional Lanyard KV Variables (per-user customization) @@ -80,6 +80,7 @@ These can be defined in Lanyard's KV store to customize the page: | `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`) | --- diff --git a/config/environment.ts b/config/environment.ts index a4da0eb..2c89b09 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,5 +1,4 @@ -import { resolve } from "node:path"; -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; const environment: Environment = { port: Number.parseInt(process.env.PORT || "8080", 10), @@ -22,16 +21,14 @@ const reviewDb = { 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; -const robotstxtPath: string | null = process.env.ROBOTS_FILE - ? resolve(process.env.ROBOTS_FILE) - : null; - function verifyRequiredVariables(): void { const requiredVariables = [ "HOST", @@ -46,7 +43,7 @@ function verifyRequiredVariables(): void { for (const key of requiredVariables) { const value = process.env[key]; if (value === undefined || value.trim() === "") { - logger.error(`Missing or empty environment variable: ${key}`); + echo.error(`Missing or empty environment variable: ${key}`); hasError = true; } } @@ -61,9 +58,9 @@ export { lanyardConfig, redisTtl, reviewDb, + timezoneAPIUrl, badgeApi, steamGridDbKey, plausibleScript, - robotstxtPath, verifyRequiredVariables, }; diff --git a/logger.json b/logger.json new file mode 100644 index 0000000..521b3bc --- /dev/null +++ b/logger.json @@ -0,0 +1,39 @@ +{ + "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 +} diff --git a/package.json b/package.json index b42bfdb..5654ef1 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "typescript": "^5.8.3" }, "dependencies": { - "@creations.works/logger": "^1.0.3", + "@atums/echo": "^1.0.3", "marked": "^15.0.7" } } diff --git a/public/css/index.css b/public/css/index.css index 157f884..8960831 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -995,3 +995,37 @@ ul { 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; + } +} diff --git a/public/js/index.js b/public/js/index.js index bb2a3e1..aaba9d6 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -3,6 +3,7 @@ const userId = head?.dataset.userId; const activityProgressMap = new Map(); const reviewURL = head?.dataset.reviewDb; +const timezoneApiUrl = head?.dataset.timezoneApi; let instanceUri = head?.dataset.instanceUri; let badgeURL = head?.dataset.badgeUrl; let socket; @@ -209,6 +210,60 @@ async function populateReviews(userId) { } } +function populateTimezone(userId) { + if (!userId || !timezoneApiUrl) return; + + let currentTimezone = null; + + async function fetchTimezone() { + try { + const res = await fetch( + `${timezoneApiUrl}/get?id=${encodeURIComponent(userId)}`, + ); + if (!res.ok) throw new Error("Failed to fetch timezone"); + + const json = await res.json(); + if (!json || typeof json.timezone !== "string") return; + + currentTimezone = json.timezone; + updateTime(); + } catch (err) { + console.error("Failed to populate timezone", err); + } + } + + function updateTime() { + if (!currentTimezone) return; + + const timezoneEl = document.querySelector(".timezone-value"); + if (!timezoneEl) return; + + const now = new Date(); + + const time24 = now.toLocaleTimeString("en-GB", { + timeZone: currentTimezone, + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const time12 = now.toLocaleTimeString("en-US", { + timeZone: currentTimezone, + hour12: true, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + timezoneEl.textContent = time24; + timezoneEl.title = `${time12} (${currentTimezone})`; + } + + fetchTimezone(); + setInterval(updateTime, 1000); +} + function setupReviewScrollObserver(userId) { const sentinel = document.createElement("div"); sentinel.className = "review-scroll-sentinel"; @@ -593,6 +648,14 @@ async function updatePresence(initialData) { setupReviewScrollObserver(userId); } + if (kv.timezone !== "false" && userId && timezoneApiUrl) { + populateTimezone(userId); + const timezoneEl = document.querySelector(".timezone-value"); + if (timezoneEl) { + timezoneEl.classList.remove("hidden"); + } + } + const platform = { mobile: data.active_on_discord_mobile, web: data.active_on_discord_web, @@ -755,7 +818,7 @@ function updateClanBadge(data) { const userInfoInner = document.querySelector(".user-info-inner"); if (!userInfoInner) return; - const clan = data?.discord_user?.clan; + const clan = data?.discord_user?.primary_guild; if (!clan || !clan.tag || !clan.identity_guild_id || !clan.badge) return; const existing = userInfoInner.querySelector(".clan-badge"); diff --git a/src/index.ts b/src/index.ts index 692b84e..71c6418 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { serverHandler } from "@/server"; +import { echo } from "@atums/echo"; import { verifyRequiredVariables } from "@config/environment"; -import { logger } from "@creations.works/logger"; async function main(): Promise { verifyRequiredVariables(); @@ -8,7 +8,7 @@ async function main(): Promise { } main().catch((error: Error) => { - logger.error(["Error initializing the server:", error]); + echo.error({ message: "Error initializing the server", error }); process.exit(1); }); diff --git a/src/routes/[id].ts b/src/routes/[id].ts index 28921ec..06c32e7 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -4,6 +4,7 @@ import { lanyardConfig, plausibleScript, reviewDb, + timezoneAPIUrl, } from "@config/environment"; import { file } from "bun"; @@ -33,6 +34,10 @@ async function handler(request: ExtendedRequest): Promise { head.setAttribute("data-review-db", reviewDb.url); } + if (timezoneAPIUrl) { + head.setAttribute("data-timezone-api", timezoneAPIUrl); + } + if (plausibleScript) { head.append(plausibleScript, { html: true }); } diff --git a/src/server.ts b/src/server.ts index c936afa..e851a58 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; -import { environment, robotstxtPath } from "@config/environment"; -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; +import { environment } from "@config/environment"; import { type BunFile, FileSystemRouter, @@ -43,16 +43,14 @@ class ServerHandler { `http://127.0.0.1:${server.port}`, ]; - logger.info(`Server running at ${accessUrls[0]}`); - logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, { - breakLine: true, - }); + echo.info(`Server running at ${accessUrls[0]}`); + echo.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`); this.logRoutes(); } private logRoutes(): void { - logger.info("Available routes:"); + echo.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -61,7 +59,7 @@ class ServerHandler { ); for (const [path, filePath] of sortedRoutes) { - logger.info(`Route: ${path}, File: ${filePath}`); + echo.info(`Route: ${path}, File: ${filePath}`); } } @@ -90,11 +88,14 @@ class ServerHandler { headers: { "Content-Type": contentType }, }); } else { - logger.warn(`File not found: ${filePath}`); + echo.warn(`File not found: ${filePath}`); response = new Response("Not Found", { status: 404 }); } } catch (error) { - logger.error([`Error serving static file: ${pathname}`, error as Error]); + echo.error({ + message: `Error serving static file: ${pathname}`, + error: error as Error, + }); response = new Response("Internal Server Error", { status: 500 }); } @@ -107,16 +108,23 @@ class ServerHandler { response: Response, ip: string | undefined, ): void { - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - request.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); + 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( @@ -139,29 +147,21 @@ class ServerHandler { } const pathname: string = new URL(request.url).pathname; - if (pathname === "/robots.txt" && robotstxtPath) { - try { - const file: BunFile = Bun.file(robotstxtPath); - if (await file.exists()) { - const fileContent: ArrayBuffer = await file.arrayBuffer(); - const contentType: string = file.type || "text/plain"; + const baseDir = resolve("public/custom"); + const customPath = resolve(baseDir, pathname.slice(1)); - response = new Response(fileContent, { - headers: { "Content-Type": contentType }, - }); - } else { - logger.warn(`File not found: ${robotstxtPath}`); - response = new Response("Not Found", { status: 404 }); - } - } catch (error) { - logger.error([ - `Error serving robots.txt: ${robotstxtPath}`, - error as Error, - ]); - response = new Response("Internal Server Error", { status: 500 }); - } + 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; } @@ -269,7 +269,10 @@ class ServerHandler { } } } catch (error: unknown) { - logger.error([`Error handling route ${request.url}:`, error as Error]); + echo.error({ + message: `Error handling route ${request.url}`, + error: error, + }); response = Response.json( { @@ -291,9 +294,11 @@ class ServerHandler { ); } + this.logRequest(extendedRequest, response, ip); return response; } } + const serverHandler: ServerHandler = new ServerHandler( environment.port, environment.host, diff --git a/src/views/index.html b/src/views/index.html index d92aaa8..1c4884f 100644 --- a/src/views/index.html +++ b/src/views/index.html @@ -1,74 +1,73 @@ - - - - - - Discord Presence + + + + + - - - - + Discord Presence - -
-
+ + + + + + +
+
+
+ +
+ + + + +
+ Users Time: +
+
-
- - - -
- -
-
-
-
- - - -
-
- - - + + +
+ +
    +
    + + +
    + + + + + + + \ No newline at end of file diff --git a/src/websocket.ts b/src/websocket.ts index 7b65476..3b00134 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,27 +1,27 @@ -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; import type { ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { - logger.info(`WebSocket received: ${message}`); + echo.info(`WebSocket received: ${message}`); try { ws.send(`You said: ${message}`); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ message: "WebSocket send error", error: error }); } } public handleOpen(ws: ServerWebSocket): void { - logger.info("WebSocket connection opened."); + echo.info("WebSocket connection opened."); try { ws.send("Welcome to the WebSocket server!"); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ message: "WebSocket send error", error: error }); } } public handleClose(ws: ServerWebSocket, code: number, reason: string): void { - logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`); } }