From 10416dbff073621ea65336fc8abf92e81b742d01 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 26 Apr 2025 10:47:12 -0400 Subject: [PATCH] add lazyload and move readme to func, add cache for readme and css --- package.json | 1 + public/js/index.js | 44 ++++++++-------- src/routes/api/art[game].ts | 6 ++- src/routes/api/css.ts | 77 +++++++++++++-------------- src/routes/api/readme.ts | 100 ++++++++++++++++++------------------ 5 files changed, 116 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 96f4946..7395618 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@creations.works/logger": "^1.0.3", "ejs": "^3.1.10", "isomorphic-dompurify": "^2.23.0", + "linkedom": "^0.18.9", "marked": "^15.0.7" } } diff --git a/public/js/index.js b/public/js/index.js index 27541b5..c930518 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -312,6 +312,28 @@ async function loadBadges(userId, options = {}) { } } +async function populateReadme(data) { + 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"); + } +} + async function updatePresence(data) { const cssLink = data.kv?.css; if (cssLink) { @@ -431,7 +453,7 @@ async function updatePresence(data) { } if (!badgesLoaded) { - await loadBadges(userId, { + loadBadges(userId, { services: [], seperated: true, cache: true, @@ -444,25 +466,7 @@ async function updatePresence(data) { const custom = data.activities?.find((a) => a.type === 4); updateCustomStatus(custom); - 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"); - } + populateReadme(data); const filtered = data.activities ?.filter((a) => a.type !== 4) diff --git a/src/routes/api/art[game].ts b/src/routes/api/art[game].ts index 006337d..c91b7b4 100644 --- a/src/routes/api/art[game].ts +++ b/src/routes/api/art[game].ts @@ -5,6 +5,7 @@ const routeDef: RouteDef = { method: "GET", accepts: "*/*", returns: "application/json", + log: false, }; async function fetchSteamGridIcon(gameName: string): Promise { @@ -53,7 +54,10 @@ async function fetchSteamGridIcon(gameName: string): Promise { async function handler(request: ExtendedRequest): Promise { if (!steamGridDbKey) { return Response.json( - { status: 503, error: "Route disabled due to missing SteamGridDB key" }, + { + status: 503, + error: "Route disabled due to missing SteamGridDB key", + }, { status: 503 }, ); } diff --git a/src/routes/api/css.ts b/src/routes/api/css.ts index 20e359e..92c4f37 100644 --- a/src/routes/api/css.ts +++ b/src/routes/api/css.ts @@ -1,4 +1,6 @@ +import { redisTtl } from "@config/environment"; import { fetch } from "bun"; +import { redis } from "bun"; const routeDef: RouteDef = { method: "GET", @@ -7,6 +9,37 @@ const routeDef: RouteDef = { log: false, }; +async function fetchAndCacheCss(url: string): Promise { + const cacheKey = `css:${url}`; + const cached = await redis.get(cacheKey); + if (cached) return cached; + + const res = await fetch(url, { + headers: { + Accept: "text/css", + }, + }); + + if (!res.ok) return null; + + if (res.headers.has("content-length")) { + const size = Number.parseInt(res.headers.get("content-length") || "0", 10); + if (size > 1024 * 50) return null; + } + + const text = await res.text(); + if (!text || text.length < 5) return null; + + const sanitized = text + .replace(/[\s\S]*?<\/script>/gi, "") + .replace(/@import\s+url\(['"]?(.*?)['"]?\);?/gi, ""); + + await redis.set(cacheKey, sanitized); + await redis.expire(cacheKey, redisTtl); + + return sanitized; +} + async function handler(request: ExtendedRequest): Promise { const { url } = request.query; @@ -23,59 +56,21 @@ async function handler(request: ExtendedRequest): Promise { ); } - const res = await fetch(url, { - headers: { - Accept: "text/css", - }, - }); + const sanitized = await fetchAndCacheCss(url); - if (!res.ok) { + if (!sanitized) { return Response.json( { success: false, error: { code: "FETCH_FAILED", - message: "Failed to fetch CSS file", + message: "Failed to fetch or sanitize CSS", }, }, { 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", diff --git a/src/routes/api/readme.ts b/src/routes/api/readme.ts index a9d05a3..a5a58dd 100644 --- a/src/routes/api/readme.ts +++ b/src/routes/api/readme.ts @@ -1,5 +1,8 @@ +import { redisTtl } from "@config/environment"; import { fetch } from "bun"; +import { redis } from "bun"; import DOMPurify from "isomorphic-dompurify"; +import { parseHTML } from "linkedom"; import { marked } from "marked"; const routeDef: RouteDef = { @@ -9,6 +12,50 @@ const routeDef: RouteDef = { log: false, }; +async function fetchAndCacheReadme(url: string): Promise { + const cacheKey = `readme:${url}`; + const cached = await redis.get(cacheKey); + if (cached) return cached; + + const res = await fetch(url, { + headers: { + Accept: "text/markdown", + }, + }); + + if (!res.ok) return null; + + if (res.headers.has("content-length")) { + const size = Number.parseInt(res.headers.get("content-length") || "0", 10); + if (size > 1024 * 100) return null; + } + + const text = await res.text(); + if (!text || text.length < 10) return null; + + let html: string; + if (/\.(html?|htm)$/i.test(url)) { + html = text; + } else { + html = await marked.parse(text); + } + + const { document } = parseHTML(html); + for (const img of Array.from(document.querySelectorAll("img"))) { + if (!img.hasAttribute("loading")) { + img.setAttribute("loading", "lazy"); + } + } + + const dirtyHtml = document.toString(); + const safe = DOMPurify.sanitize(dirtyHtml) || ""; + + await redis.set(cacheKey, safe); + await redis.expire(cacheKey, redisTtl); + + return safe; +} + async function handler(request: ExtendedRequest): Promise { const { url } = request.query; @@ -29,68 +76,21 @@ async function handler(request: ExtendedRequest): Promise { ); } - const res = await fetch(url, { - headers: { - Accept: "text/markdown", - }, - }); + const safe = await fetchAndCacheReadme(url); - if (!res.ok) { + if (!safe) { return Response.json( { success: false, error: { code: "FETCH_FAILED", - message: "Failed to fetch the file", + message: "Failed to fetch or process 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",