diff --git a/README.md b/README.md index 396d1b3..a7a1269 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ These can be defined in Lanyard's KV store to customize the page: | `stars` | Enables starfield background (`true` / `false`) | | `badges` | Enables badge fetching (`true` / `false`) | | `readme` | URL to a README displayed on the profile (`.md` or `.html`) | -| `colors` | Enables avatar-based color theming (uses `node-vibrant`) | +| `css` | URL to a css to change styles on the page, no import or require allowed | --- diff --git a/biome.json b/biome.json index fa06b32..46ee8c9 100644 --- a/biome.json +++ b/biome.json @@ -26,7 +26,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "correctness": { + "noUnusedImports": "error" + } } }, "javascript": { diff --git a/package.json b/package.json index 1a81a77..96f4946 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "@creations.works/logger": "^1.0.3", "ejs": "^3.1.10", "isomorphic-dompurify": "^2.23.0", - "marked": "^15.0.7", - "node-vibrant": "^4.0.3" + "marked": "^15.0.7" } } diff --git a/public/css/index.css b/public/css/index.css index b10bdc8..145949e 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -40,6 +40,39 @@ } } +#loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 99999; + + transition: opacity 0.5s ease; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 5px solid var(--border-color); + border-top: 5px solid var(--progress-fill); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + /* actual styles below */ body { font-family: system-ui, sans-serif; diff --git a/public/css/root.css b/public/css/root.css new file mode 100644 index 0000000..dec65f3 --- /dev/null +++ b/public/css/root.css @@ -0,0 +1,29 @@ +:root { + --background: #0e0e10; + --readme-bg: #1a1a1d; + --card-bg: #1e1f22; + --card-hover-bg: #2a2a2d; + --border-color: #2e2e30; + + --text-color: #ffffff; + --text-subtle: #bbb; + --text-secondary: #b5bac1; + --text-muted: #888; + --link-color: #00b0f4; + + --button-bg: #5865f2; + --button-hover-bg: #4752c4; + --button-disabled-bg: #2d2e31; + + --progress-bg: #f23f43; + --progress-fill: #5865f2; + + --status-online: #23a55a; + --status-idle: #f0b232; + --status-dnd: #e03e3e; + --status-offline: #747f8d; + --status-streaming: #b700ff; + + --blockquote-color: #aaa; + --code-bg: #2e2e30; +} diff --git a/public/js/index.js b/public/js/index.js index 0181d5c..0dcd03e 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -353,13 +353,45 @@ if (badgeURL && badgeURL !== "null" && userId) { }); } -function updatePresence(data) { - const avatarWrapper = document.querySelector(".avatar-wrapper"); - const statusIndicator = avatarWrapper?.querySelector(".status-indicator"); - const mobileIcon = avatarWrapper?.querySelector(".platform-icon.mobile-only"); +async function updatePresence(data) { + const cssLink = data.kv?.css; - const userInfo = document.querySelector(".user-info"); - const customStatus = userInfo?.querySelector(".custom-status"); + if (cssLink) { + try { + const res = await fetch(`/api/css?url=${encodeURIComponent(cssLink)}`); + if (!res.ok) throw new Error("Failed to fetch CSS"); + + const cssText = await res.text(); + const style = document.createElement("style"); + style.textContent = cssText; + document.head.appendChild(style); + } catch (err) { + console.error("Failed to load CSS", err); + } + } + + const avatarWrapper = document.querySelector(".avatar-wrapper"); + + const avatarImg = document.querySelector(".avatar-wrapper .avatar"); + const usernameEl = document.querySelector(".username"); + + if (avatarImg && data.discord_user?.avatar) { + const newAvatarUrl = `https://cdn.discordapp.com/avatars/${data.discord_user.id}/${data.discord_user.avatar}`; + avatarImg.src = newAvatarUrl; + avatarImg.classList.remove("hidden"); + + const siteIcon = document.getElementById("site-icon"); + + if (siteIcon) { + siteIcon.href = newAvatarUrl; + } + } + if (usernameEl) { + const username = + data.discord_user.global_name || data.discord_user.username; + usernameEl.textContent = username; + document.title = username; + } const platform = { mobile: data.active_on_discord_mobile, @@ -374,37 +406,51 @@ function updatePresence(data) { status = data.discord_status; } - if (statusIndicator) { - statusIndicator.className = `status-indicator ${status}`; - } + let updatedStatusIndicator = avatarWrapper.querySelector(".status-indicator"); + const updatedMobileIcon = avatarWrapper.querySelector( + ".platform-icon.mobile-only", + ); - if (platform.mobile && !mobileIcon) { + if (platform.mobile && !updatedMobileIcon) { avatarWrapper.innerHTML += ` `; - } else if (!platform.mobile && mobileIcon) { - mobileIcon.remove(); + } else if (!platform.mobile && updatedMobileIcon) { + updatedMobileIcon.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 ? `${custom.state}` : ""} - `; + updatedStatusIndicator = avatarWrapper.querySelector(".status-indicator"); + + if (updatedStatusIndicator) { + updatedStatusIndicator.className = `status-indicator ${status}`; } + const readmeSection = document.querySelector(".readme"); + + if (readmeSection && data.kv?.readme) { + const url = data.kv.readme; + try { + const res = await fetch(`/api/readme?url=${encodeURIComponent(url)}`); + if (!res.ok) throw new Error("Failed to fetch readme"); + + const text = await res.text(); + + readmeSection.innerHTML = `
${text}
`; + readmeSection.classList.remove("hidden"); + } catch (err) { + console.error("Failed to load README", err); + readmeSection.classList.add("hidden"); + } + } else if (readmeSection) { + readmeSection.classList.add("hidden"); + } + + const custom = data.activities?.find((a) => a.type === 4); + updateCustomStatus(custom); + const filtered = data.activities ?.filter((a) => a.type !== 4) ?.sort((a, b) => { @@ -428,6 +474,47 @@ function updatePresence(data) { updateElapsedAndProgress(); getAllNoAsset(); } + + if (data.kv?.snow === "true") loadEffectScript("snow"); + if (data.kv?.rain === "true") loadEffectScript("rain"); + if (data.kv?.stars === "true") loadEffectScript("stars"); + + const loadingOverlay = document.getElementById("loading-overlay"); + if (loadingOverlay) { + loadingOverlay.style.opacity = "0"; + setTimeout(() => loadingOverlay.remove(), 500); + } +} + +function updateCustomStatus(custom) { + const userInfoInner = document.querySelector(".user-info"); + const customStatus = userInfoInner?.querySelector(".custom-status"); + + if (!userInfoInner) return; + + if (custom) { + let emojiHTML = ""; + if (custom.emoji?.id) { + const emojiUrl = `https://cdn.discordapp.com/emojis/${custom.emoji.id}.${custom.emoji.animated ? "gif" : "png"}`; + emojiHTML = `${custom.emoji.name}`; + } else if (custom.emoji?.name) { + emojiHTML = `${custom.emoji.name} `; + } + + const html = ` +

+ ${emojiHTML}${custom.state ? `${custom.state}` : ""} +

+ `; + + if (customStatus) { + customStatus.outerHTML = html; + } else { + userInfoInner.insertAdjacentHTML("beforeend", html); + } + } else if (customStatus) { + customStatus.remove(); + } } async function getAllNoAsset() { @@ -454,3 +541,13 @@ async function getAllNoAsset() { } } } + +function loadEffectScript(effect) { + const existing = document.querySelector(`script[data-effect="${effect}"]`); + if (existing) return; + + const script = document.createElement("script"); + script.src = `/public/js/${effect}.js`; + script.dataset.effect = effect; + document.head.appendChild(script); +} diff --git a/public/js/snow.js b/public/js/snow.js index 05048a8..e1027fc 100644 --- a/public/js/snow.js +++ b/public/js/snow.js @@ -1,84 +1,82 @@ -document.addEventListener("DOMContentLoaded", () => { - const snowContainer = document.createElement("div"); - snowContainer.style.position = "fixed"; - snowContainer.style.top = "0"; - snowContainer.style.left = "0"; - snowContainer.style.width = "100vw"; - snowContainer.style.height = "100vh"; - snowContainer.style.pointerEvents = "none"; - document.body.appendChild(snowContainer); +const snowContainer = document.createElement("div"); +snowContainer.style.position = "fixed"; +snowContainer.style.top = "0"; +snowContainer.style.left = "0"; +snowContainer.style.width = "100vw"; +snowContainer.style.height = "100vh"; +snowContainer.style.pointerEvents = "none"; +document.body.appendChild(snowContainer); - const maxSnowflakes = 60; - const snowflakes = []; - const mouse = { x: -100, y: -100 }; +const maxSnowflakes = 60; +const snowflakes = []; +const mouse = { x: -100, y: -100 }; - document.addEventListener("mousemove", (e) => { - mouse.x = e.clientX; - mouse.y = e.clientY; - }); +document.addEventListener("mousemove", (e) => { + mouse.x = e.clientX; + mouse.y = e.clientY; +}); - const createSnowflake = () => { - if (snowflakes.length >= maxSnowflakes) { - const oldestSnowflake = snowflakes.shift(); - snowContainer.removeChild(oldestSnowflake); - } - - const snowflake = document.createElement("div"); - snowflake.classList.add("snowflake"); - snowflake.style.position = "absolute"; - snowflake.style.width = `${Math.random() * 3 + 2}px`; - snowflake.style.height = snowflake.style.width; - snowflake.style.background = "white"; - snowflake.style.borderRadius = "50%"; - snowflake.style.opacity = Math.random(); - snowflake.style.left = `${Math.random() * window.innerWidth}px`; - snowflake.style.top = `-${snowflake.style.height}`; - snowflake.speed = Math.random() * 3 + 2; - snowflake.directionX = (Math.random() - 0.5) * 0.5; - snowflake.directionY = Math.random() * 0.5 + 0.5; - - snowflakes.push(snowflake); - snowContainer.appendChild(snowflake); - }; - - setInterval(createSnowflake, 80); - - function updateSnowflakes() { - snowflakes.forEach((snowflake, index) => { - const rect = snowflake.getBoundingClientRect(); - - const dx = rect.left + rect.width / 2 - mouse.x; - const dy = rect.top + rect.height / 2 - mouse.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 30) { - snowflake.directionX += (dx / distance) * 0.02; - snowflake.directionY += (dy / distance) * 0.02; - } else { - snowflake.directionX += (Math.random() - 0.5) * 0.01; - snowflake.directionY += (Math.random() - 0.5) * 0.01; - } - - snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`; - snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`; - - if (rect.top + rect.height >= window.innerHeight) { - snowContainer.removeChild(snowflake); - snowflakes.splice(index, 1); - } - - if ( - rect.left > window.innerWidth || - rect.top > window.innerHeight || - rect.left < 0 - ) { - snowflake.style.left = `${Math.random() * window.innerWidth}px`; - snowflake.style.top = `-${snowflake.style.height}`; - } - }); - - requestAnimationFrame(updateSnowflakes); +const createSnowflake = () => { + if (snowflakes.length >= maxSnowflakes) { + const oldestSnowflake = snowflakes.shift(); + snowContainer.removeChild(oldestSnowflake); } - updateSnowflakes(); -}); + const snowflake = document.createElement("div"); + snowflake.classList.add("snowflake"); + snowflake.style.position = "absolute"; + snowflake.style.width = `${Math.random() * 3 + 2}px`; + snowflake.style.height = snowflake.style.width; + snowflake.style.background = "white"; + snowflake.style.borderRadius = "50%"; + snowflake.style.opacity = Math.random(); + snowflake.style.left = `${Math.random() * window.innerWidth}px`; + snowflake.style.top = `-${snowflake.style.height}`; + snowflake.speed = Math.random() * 3 + 2; + snowflake.directionX = (Math.random() - 0.5) * 0.5; + snowflake.directionY = Math.random() * 0.5 + 0.5; + + snowflakes.push(snowflake); + snowContainer.appendChild(snowflake); +}; + +setInterval(createSnowflake, 80); + +function updateSnowflakes() { + snowflakes.forEach((snowflake, index) => { + const rect = snowflake.getBoundingClientRect(); + + const dx = rect.left + rect.width / 2 - mouse.x; + const dy = rect.top + rect.height / 2 - mouse.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 30) { + snowflake.directionX += (dx / distance) * 0.02; + snowflake.directionY += (dy / distance) * 0.02; + } else { + snowflake.directionX += (Math.random() - 0.5) * 0.01; + snowflake.directionY += (Math.random() - 0.5) * 0.01; + } + + snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`; + snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`; + + if (rect.top + rect.height >= window.innerHeight) { + snowContainer.removeChild(snowflake); + snowflakes.splice(index, 1); + } + + if ( + rect.left > window.innerWidth || + rect.top > window.innerHeight || + rect.left < 0 + ) { + snowflake.style.left = `${Math.random() * window.innerWidth}px`; + snowflake.style.top = `-${snowflake.style.height}`; + } + }); + + requestAnimationFrame(updateSnowflakes); +} + +updateSnowflakes(); diff --git a/public/js/stars.js b/public/js/stars.js index 093b21a..d35d995 100644 --- a/public/js/stars.js +++ b/public/js/stars.js @@ -1,67 +1,65 @@ -document.addEventListener("DOMContentLoaded", () => { - const container = document.createElement("div"); - container.style.position = "fixed"; - container.style.top = "0"; - container.style.left = "0"; - container.style.width = "100vw"; - container.style.height = "100vh"; - container.style.pointerEvents = "none"; - container.style.overflow = "hidden"; - container.style.zIndex = "9999"; - document.body.appendChild(container); +const container = document.createElement("div"); +container.style.position = "fixed"; +container.style.top = "0"; +container.style.left = "0"; +container.style.width = "100vw"; +container.style.height = "100vh"; +container.style.pointerEvents = "none"; +container.style.overflow = "hidden"; +container.style.zIndex = "9999"; +document.body.appendChild(container); - for (let i = 0; i < 60; i++) { - const star = document.createElement("div"); - const size = Math.random() * 2 + 1; - star.style.position = "absolute"; - star.style.width = `${size}px`; - star.style.height = `${size}px`; - star.style.background = "white"; - star.style.borderRadius = "50%"; - star.style.opacity = Math.random(); - star.style.top = `${Math.random() * 100}vh`; - star.style.left = `${Math.random() * 100}vw`; - star.style.animation = `twinkle ${Math.random() * 3 + 2}s infinite alternate ease-in-out`; - container.appendChild(star); - } +for (let i = 0; i < 60; i++) { + const star = document.createElement("div"); + const size = Math.random() * 2 + 1; + star.style.position = "absolute"; + star.style.width = `${size}px`; + star.style.height = `${size}px`; + star.style.background = "white"; + star.style.borderRadius = "50%"; + star.style.opacity = Math.random(); + star.style.top = `${Math.random() * 100}vh`; + star.style.left = `${Math.random() * 100}vw`; + star.style.animation = `twinkle ${Math.random() * 3 + 2}s infinite alternate ease-in-out`; + container.appendChild(star); +} - function createShootingStar() { - const star = document.createElement("div"); - star.classList.add("shooting-star"); +function createShootingStar() { + const star = document.createElement("div"); + star.classList.add("shooting-star"); - let x = Math.random() * window.innerWidth * 0.8; - let y = Math.random() * window.innerHeight * 0.3; - const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8; - const speed = 10; - const totalFrames = 60; + let x = Math.random() * window.innerWidth * 0.8; + let y = Math.random() * window.innerHeight * 0.3; + const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8; + const speed = 10; + const totalFrames = 60; - const deg = angle * (180 / Math.PI); + const deg = angle * (180 / Math.PI); + star.style.left = `${x}px`; + star.style.top = `${y}px`; + star.style.transform = `rotate(${deg}deg)`; + + container.appendChild(star); + + let frame = 0; + function animate() { + x += Math.cos(angle) * speed; + y += Math.sin(angle) * speed; star.style.left = `${x}px`; star.style.top = `${y}px`; - star.style.transform = `rotate(${deg}deg)`; + star.style.opacity = `${1 - frame / totalFrames}`; - container.appendChild(star); - - let frame = 0; - function animate() { - x += Math.cos(angle) * speed; - y += Math.sin(angle) * speed; - star.style.left = `${x}px`; - star.style.top = `${y}px`; - star.style.opacity = `${1 - frame / totalFrames}`; - - frame++; - if (frame < totalFrames) { - requestAnimationFrame(animate); - } else { - container.removeChild(star); - } + frame++; + if (frame < totalFrames) { + requestAnimationFrame(animate); + } else { + container.removeChild(star); } - - animate(); } - setInterval(() => { - if (Math.random() < 0.3) createShootingStar(); - }, 1000); -}); + animate(); +} + +setInterval(() => { + if (Math.random() < 0.3) createShootingStar(); +}, 1000); diff --git a/src/helpers/colors.ts b/src/helpers/colors.ts deleted file mode 100644 index 43a74e7..0000000 --- a/src/helpers/colors.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { fetch } from "bun"; -import { Vibrant } from "node-vibrant/node"; - -export async function getImageColors( - url: string, - hex?: boolean, -): Promise { - if (!url) return null; - - if (typeof url !== "string" || !url.startsWith("http")) return null; - - let res: Response; - try { - res = await fetch(url); - } catch { - return null; - } - - if (!res.ok) return null; - - const type: string | null = res.headers.get("content-type"); - if (!type?.startsWith("image/")) return null; - - const buffer: Buffer = Buffer.from(await res.arrayBuffer()); - const base64: string = buffer.toString("base64"); - const colors: Palette = await Vibrant.from(buffer).getPalette(); - - return { - img: `data:${type};base64,${base64}`, - colors: hex - ? { - Muted: rgbToHex(safeRgb(colors.Muted)), - LightVibrant: rgbToHex(safeRgb(colors.LightVibrant)), - Vibrant: rgbToHex(safeRgb(colors.Vibrant)), - LightMuted: rgbToHex(safeRgb(colors.LightMuted)), - DarkVibrant: rgbToHex(safeRgb(colors.DarkVibrant)), - DarkMuted: rgbToHex(safeRgb(colors.DarkMuted)), - } - : colors, - }; -} - -function safeRgb(swatch: Swatch | null | undefined): number[] { - return Array.isArray(swatch?.rgb) ? (swatch.rgb ?? [0, 0, 0]) : [0, 0, 0]; -} - -export function rgbToHex(rgb: number[]): string { - return `#${rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`; -} diff --git a/src/helpers/lanyard.ts b/src/helpers/lanyard.ts deleted file mode 100644 index c13ba06..0000000 --- a/src/helpers/lanyard.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { lanyardConfig } from "@config/environment"; -import { fetch } from "bun"; -import DOMPurify from "isomorphic-dompurify"; -import { marked } from "marked"; - -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; -} - -export async function handleReadMe(data: LanyardData): Promise { - const userReadMe: string | null = data.kv?.readme; - const validExtension = /\.(md|markdown|txt|html?)$/i; - - if ( - !userReadMe || - !userReadMe.startsWith("http") || - !validExtension.test(userReadMe) - ) { - return null; - } - - try { - const res: Response = await fetch(userReadMe, { - headers: { - Accept: "text/markdown", - }, - }); - - if (!res.ok) return null; - - if (res.headers.has("content-length")) { - const size: number = Number.parseInt( - res.headers.get("content-length") || "0", - 10, - ); - if (size > 1024 * 100) return null; - } - - const text: string = await res.text(); - if (!text || text.length < 10) return null; - - let html: string; - if ( - userReadMe.toLowerCase().endsWith(".html") || - userReadMe.toLowerCase().endsWith(".htm") - ) { - html = text; - } else { - html = await marked.parse(text); - } - - const safe: string | null = DOMPurify.sanitize(html); - return safe; - } catch { - return null; - } -} diff --git a/src/routes/[id].ts b/src/routes/[id].ts index d859502..201d0a4 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -1,7 +1,5 @@ -import { getImageColors } from "@/helpers/colors"; import { badgeApi, lanyardConfig } from "@config/environment"; import { renderEjsTemplate } from "@helpers/ejs"; -import { getLanyardData, handleReadMe } from "@helpers/lanyard"; const routeDef: RouteDef = { method: "GET", @@ -11,67 +9,18 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { id } = request.params; - const data: LanyardResponse = await getLanyardData( - id || lanyardConfig.userId, - ); - - if (!data.success) { - return await renderEjsTemplate("error", { - message: data.error.message, - }); - } - - let instance: string = lanyardConfig.instance; - - if (instance.endsWith("/")) { - instance = instance.slice(0, -1); - } - - if (instance.startsWith("http://") || instance.startsWith("https://")) { - instance = instance.slice(instance.indexOf("://") + 3); - } - - const presence: LanyardData = data.data; - const readme: string | Promise | null = await handleReadMe(presence); - - let status: string; - if (presence.activities.some((activity) => activity.type === 1)) { - status = "streaming"; - } else { - status = presence.discord_status; - } - - const avatar: string = presence.discord_user.avatar - ? `https://cdn.discordapp.com/avatars/${presence.discord_user.id}/${presence.discord_user.avatar}` - : `https://cdn.discordapp.com/embed/avatars/${Math.floor(Math.random() * 5)}.png`; - - let colors: ImageColorResult | null = null; - if (presence.kv.colors === "true") { - colors = await getImageColors(avatar, true); - } + const instance = lanyardConfig.instance + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); const ejsTemplateData: EjsTemplateData = { - title: presence.discord_user.global_name || presence.discord_user.username, - username: - presence.discord_user.global_name || presence.discord_user.username, - status: 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, - }, + title: "Discord Profile", + username: "", + user: { id: id || lanyardConfig.userId }, instance: instance, - readme: readme, - badgeApi: presence.kv.badges !== "false" ? badgeApi : null, - avatar: avatar, - colors: colors?.colors ?? {}, - extraOptions: { - snow: presence.kv.snow === "true", - rain: presence.kv.rain === "true", - stars: presence.kv.stars === "true", - }, + badgeApi: badgeApi, + avatar: `https://cdn.discordapp.com/embed/avatars/${Math.floor(Math.random() * 5)}.png`, + extraOptions: {}, }; return await renderEjsTemplate("index", ejsTemplateData); diff --git a/src/routes/api/colors.ts b/src/routes/api/colors.ts deleted file mode 100644 index ef97012..0000000 --- a/src/routes/api/colors.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getImageColors } from "@helpers/colors"; - -const routeDef: RouteDef = { - method: "GET", - accepts: "*/*", - returns: "application/json", -}; - -async function handler(request: ExtendedRequest): Promise { - const { url } = request.query; - - const result: ImageColorResult | null = await getImageColors(url, true); - await getImageColors(url); - - if (!result) { - return new Response("Invalid URL", { - status: 400, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - "Access-Control-Allow-Origin": "*", - }, - }); - } - - const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(result)); - - return new Response(compressed, { - headers: { - "Content-Type": "application/json", - "Content-Encoding": "gzip", - "Cache-Control": "public, max-age=31536000, immutable", - "Access-Control-Allow-Origin": "*", - }, - }); -} - -export { handler, routeDef }; diff --git a/src/routes/api/css.ts b/src/routes/api/css.ts new file mode 100644 index 0000000..20e359e --- /dev/null +++ b/src/routes/api/css.ts @@ -0,0 +1,90 @@ +import { fetch } from "bun"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "*/*", + log: false, +}; + +async function handler(request: ExtendedRequest): Promise { + const { url } = request.query; + + if (!url || !url.startsWith("http") || !/\.css$/i.test(url)) { + return Response.json( + { + success: false, + error: { + code: "INVALID_URL", + message: "Invalid URL provided", + }, + }, + { status: 400 }, + ); + } + + const res = await fetch(url, { + headers: { + Accept: "text/css", + }, + }); + + if (!res.ok) { + return Response.json( + { + success: false, + error: { + code: "FETCH_FAILED", + message: "Failed to fetch CSS file", + }, + }, + { status: 400 }, + ); + } + + if (res.headers.has("content-length")) { + const size = Number.parseInt(res.headers.get("content-length") || "0", 10); + if (size > 1024 * 50) { + return Response.json( + { + success: false, + error: { + code: "FILE_TOO_LARGE", + message: "CSS file exceeds 50KB limit", + }, + }, + { status: 400 }, + ); + } + } + + const text = await res.text(); + if (!text || text.length < 5) { + return Response.json( + { + success: false, + error: { + code: "INVALID_CONTENT", + message: "CSS content is too small or invalid", + }, + }, + { status: 400 }, + ); + } + + const sanitized = text + .replace(/[\s\S]*?<\/script>/gi, "") + .replace(/@import\s+url\(['"]?(.*?)['"]?\);?/gi, ""); + + return new Response(sanitized, { + headers: { + "Content-Type": "text/css", + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + status: 200, + }); +} + +export { handler, routeDef }; diff --git a/src/routes/api/readme.ts b/src/routes/api/readme.ts new file mode 100644 index 0000000..a9d05a3 --- /dev/null +++ b/src/routes/api/readme.ts @@ -0,0 +1,105 @@ +import { fetch } from "bun"; +import DOMPurify from "isomorphic-dompurify"; +import { marked } from "marked"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "*/*", + log: false, +}; + +async function handler(request: ExtendedRequest): Promise { + const { url } = request.query; + + if ( + !url || + !url.startsWith("http") || + !/\.(md|markdown|txt|html?)$/i.test(url) + ) { + return Response.json( + { + success: false, + error: { + code: "INVALID_URL", + message: "Invalid URL provided", + }, + }, + { status: 400 }, + ); + } + + const res = await fetch(url, { + headers: { + Accept: "text/markdown", + }, + }); + + if (!res.ok) { + return Response.json( + { + success: false, + error: { + code: "FETCH_FAILED", + message: "Failed to fetch the file", + }, + }, + { status: 400 }, + ); + } + + if (res.headers.has("content-length")) { + const size = Number.parseInt(res.headers.get("content-length") || "0", 10); + if (size > 1024 * 100) { + return Response.json( + { + success: false, + error: { + code: "FILE_TOO_LARGE", + message: "File size exceeds 100KB limit", + }, + }, + { status: 400 }, + ); + } + } + + const text = await res.text(); + if (!text || text.length < 10) { + return Response.json( + { + success: false, + error: { + code: "INVALID_CONTENT", + message: "File is too small or invalid", + }, + }, + { status: 400 }, + ); + } + + let html: string; + + if ( + url.toLowerCase().endsWith(".html") || + url.toLowerCase().endsWith(".htm") + ) { + html = text; + } else { + html = await marked.parse(text); + } + + const safe = DOMPurify.sanitize(html) || ""; + + return new Response(safe, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + status: 200, + }); +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts index 39004ce..2bc28ae 100644 --- a/src/server.ts +++ b/src/server.ts @@ -109,6 +109,8 @@ class ServerHandler { let requestBody: unknown = {}; let response: Response; + let logRequest = true; + if (match) { const { filePath, params, query } = match; @@ -119,6 +121,8 @@ class ServerHandler { ? contentType.split(";")[0].trim() : null; + logRequest = routeModule.routeDef.log !== false; + if ( routeModule.routeDef.needsBody === "json" && actualContentType === "application/json" @@ -227,28 +231,30 @@ class ServerHandler { ); } - const headers = request.headers; - let ip = server.requestIP(request)?.address; + if (logRequest) { + const headers = request.headers; + let ip = server.requestIP(request)?.address; - if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { - ip = - headers.get("CF-Connecting-IP")?.trim() || - headers.get("X-Real-IP")?.trim() || - headers.get("X-Forwarded-For")?.split(",")[0].trim() || - "unknown"; + if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { + ip = + headers.get("CF-Connecting-IP")?.trim() || + headers.get("X-Real-IP")?.trim() || + headers.get("X-Forwarded-For")?.split(",")[0].trim() || + "unknown"; + } + + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); } - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - return response; } } diff --git a/src/views/index.ejs b/src/views/index.ejs index a313b4b..8fff4b8 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -5,230 +5,48 @@ - <% - const displayName = username.endsWith('s') ? `${username}'` : `${username}'s`; - const profileUrl = `https://discord.com/users/${user.id}`; - %> - - - - - - <%= title %> + Discord Presence - - - <% if (extraOptions.snow) { %> - - <% } %> - <% if(extraOptions.rain) { %> - - <% } %> - <% if (extraOptions.stars) { %> - - <% } %> + - - + - <%- include("partial/style.ejs") %> - +
+
+
+ +
- Avatar - <% if (user.avatar_decoration_data) { %> - Decoration - <% } %> - <% if (platform.mobile) { %> - - - - <% } else { %> -
- <% } %> + +
- <% if(badgeApi) { %> -
- <% } %> - <% - let filtered = activities - .filter(a => a.type !== 4) - .sort((a, b) => { - const priority = { 2: 0, 1: 1, 3: 2 }; - const aPriority = priority[a.type] ?? 99; - const bPriority = priority[b.type] ?? 99; - return aPriority - bPriority; - }); - %> + -

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; + +
      - let art = null; - let smallArt = null; - - function resolveActivityImage(img, applicationId) { - if (!img) return null; - if (img.startsWith("mp:external/")) { - return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; - } - if (img.includes("/https/")) { - const clean = img.split("/https/")[1]; - return clean ? `https://${clean}` : null; - } - if (img.startsWith("spotify:")) { - return `https://i.scdn.co/image/${img.split(":")[1]}`; - } - return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; - } - - if (activity.assets) { - art = resolveActivityImage(activity.assets.large_image, activity.application_id); - smallArt = resolveActivityImage(activity.assets.small_image, activity.application_id); - } - - const activityTypeMap = { - 0: "Playing", - 1: "Streaming", - 2: "Listening", - 3: "Watching", - 4: "Custom Status", - 5: "Competing", - }; - - const activityType = activity.name === "Spotify" - ? "Listening to Spotify" - : activity.name === "TIDAL" - ? "Listening to TIDAL" - : activityTypeMap[activity.type] || "Playing"; - %> -
    • -
      -
      - <%= activityType %> - <% if (start && progress === null) { %> -
      - <% const started = new Date(start); %> - - Since: <%= started.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) %> - -
      - <% } %> -
      -
      -
      "> - " <%= activity.assets?.large_text ? `title="${activity.assets.large_text}"` : '' %>> - " src="<%= smallArt %>" <%= activity.assets?.small_text ? `title="${activity.assets.small_text}"` : '' %>> -
      -
      -
      - <% - const isMusic = activity.type === 2 || activity.type === 3; - const primaryLine = isMusic ? activity.details : activity.name; - const secondaryLine = isMusic ? activity.state : activity.details; - const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state; - %> -
      -
      - <%= primaryLine %> -
      - <% if (secondaryLine) { %> -
      <%= secondaryLine %>
      - <% } %> - <% if (tertiaryLine) { %> -
      <%= tertiaryLine %>
      - <% } %> -
      -
      - <% if (activity.buttons && activity.buttons.length > 0) { %> -
      - <% activity.buttons.forEach((button, index) => { - const buttonLabel = typeof button === 'string' ? button : button.label; - let buttonUrl = null; - if (typeof button === 'object' && button.url) { - buttonUrl = button.url; - } else if (index === 0 && activity.url) { - buttonUrl = activity.url; - } - %> - <% if (buttonUrl) { %> - <%= buttonLabel %> - <% } %> - <% }) %> -
      - <% } %> -
      -
      -
      -
      - <% if (progress !== null) { %> -
      -
      >
      -
      - <% if (start && end) { %> -
      - - <%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %> -
      - <% } %> - <% } %> -
      -
    • - <% }); %> -
    - - <% if (readme) { %> -
    -
    <%- readme %>
    + - <% } %> + + diff --git a/src/views/partial/style.ejs b/src/views/partial/style.ejs deleted file mode 100644 index cccf3a2..0000000 --- a/src/views/partial/style.ejs +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/types/routes.d.ts b/types/routes.d.ts index 6af6a4b..afb9b2a 100644 --- a/types/routes.d.ts +++ b/types/routes.d.ts @@ -3,6 +3,7 @@ type RouteDef = { accepts: string | null | string[]; returns: string; needsBody?: "multipart" | "json"; + log?: boolean; }; type RouteModule = { @@ -13,10 +14,3 @@ type RouteModule = { ) => Promise | Response; routeDef: RouteDef; }; - -type Palette = Awaited>; -type Swatch = Awaited>; -type ImageColorResult = { - img: string; - colors: Palette | Record; -};