From fd04145edc312b02b6eb1a998d4682a19b7ae912 Mon Sep 17 00:00:00 2001 From: Martin Sulikowski Date: Tue, 10 Feb 2026 23:58:45 +0100 Subject: [PATCH 1/3] Fix lot of issues reported by internal audit. --- .gitignore | 1 + app/api/builds/builds.ts | 30 +-- app/api/builds/route.ts | 65 ++++++ app/api/docs/search-index/route.ts | 122 ++++++++---- app/api/og/route.tsx | 86 +++++++- app/builds/page.tsx | 58 +++--- app/error.tsx | 33 +++ app/global-error.tsx | 35 ++++ app/layout.tsx | 2 - app/loading.tsx | 13 ++ app/privacy-policy/layout.tsx | 14 -- app/privacy-policy/opengraph-image.tsx | 25 --- app/privacy-policy/page.tsx | 266 ------------------------- app/robots.ts | 2 +- app/sitemap.ts | 6 - components/builds/build-controls.tsx | 6 +- components/builds/build-table.tsx | 2 +- components/cookie-consent-modal.tsx | 248 ----------------------- components/footer/footer.tsx | 1 - components/ui/error-boundary.tsx | 1 - hooks/use-cookie-consent.ts | 68 ------- lib/builds/projects.ts | 23 +++ lib/cookie-utils.ts | 57 ------ next.config.mjs | 55 ++++- package.json | 4 +- 25 files changed, 431 insertions(+), 792 deletions(-) create mode 100644 app/api/builds/route.ts create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/loading.tsx delete mode 100644 app/privacy-policy/layout.tsx delete mode 100644 app/privacy-policy/opengraph-image.tsx delete mode 100644 app/privacy-policy/page.tsx delete mode 100644 components/cookie-consent-modal.tsx delete mode 100644 hooks/use-cookie-consent.ts create mode 100644 lib/builds/projects.ts delete mode 100644 lib/cookie-utils.ts diff --git a/.gitignore b/.gitignore index 82bde21d..a7fc29b3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # misc .DS_Store *.pem +nul # debug npm-debug.log* diff --git a/app/api/builds/builds.ts b/app/api/builds/builds.ts index bccd4cb3..242388fb 100644 --- a/app/api/builds/builds.ts +++ b/app/api/builds/builds.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { Project } from "@/lib/builds/projects"; export const ModrinthFileSchema = z.object({ url: z.string(), @@ -19,28 +20,6 @@ export const ModrinthVersionSchema = z.object({ export type ModrinthVersion = z.infer; -export interface Project { - id: string; - name: string; - githubRepo: string; - modrinthId?: string; -} - -export const PROJECTS: Project[] = [ - { - id: "eternalcore", - name: "EternalCore", - githubRepo: "EternalCodeTeam/EternalCore", - modrinthId: "eternalcore", - }, - { - id: "eternalcombat", - name: "EternalCombat", - githubRepo: "EternalCodeTeam/EternalCombat", - modrinthId: "eternalcombat", - }, -]; - async function fetchModrinthVersions( project: Project, types: ("release" | "beta" | "alpha")[] @@ -50,7 +29,12 @@ async function fetchModrinthVersions( } try { - const res = await fetch(`https://api.modrinth.com/v2/project/${project.modrinthId}/version`); + const res = await fetch(`https://api.modrinth.com/v2/project/${project.modrinthId}/version`, { + next: { + revalidate: 300, + tags: [`modrinth-builds-${project.modrinthId}`], + }, + }); if (!res.ok) { if (res.status === 404) { return []; // Project might not exist yet diff --git a/app/api/builds/route.ts b/app/api/builds/route.ts new file mode 100644 index 00000000..01e44d13 --- /dev/null +++ b/app/api/builds/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { fetchDevBuilds, fetchStableBuilds } from "@/app/api/builds/builds"; +import { type BuildTab, PROJECTS } from "@/lib/builds/projects"; + +const BuildQuerySchema = z.object({ + project: z.string().trim().min(1), + type: z.enum(["STABLE", "DEV"]).default("STABLE"), +}); + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const query = BuildQuerySchema.safeParse(Object.fromEntries(url.searchParams.entries())); + + if (!query.success) { + return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 }); + } + + const project = PROJECTS.find((item) => item.id === query.data.project); + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const versions = + query.data.type === "STABLE" + ? await fetchStableBuilds(project) + : await fetchDevBuilds(project); + + const builds = mapToBuildResponse(versions, query.data.type, project.modrinthId); + + return NextResponse.json(builds, { + headers: { + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=86400", + }, + }); + } catch (error) { + console.error("[api/builds] Unexpected error", error); + return NextResponse.json({ error: "Failed to fetch builds" }, { status: 500 }); + } +} + +function mapToBuildResponse( + versions: Awaited>, + type: BuildTab, + modrinthId?: string +) { + return versions + .toSorted((a, b) => Date.parse(b.date_published) - Date.parse(a.date_published)) + .map((version) => { + const primaryFile = version.files.find((file) => file.primary) ?? version.files[0]; + + return { + id: version.id, + name: version.name, + type, + date: version.date_published, + downloadUrl: primaryFile?.url ?? "", + version: version.version_number, + runUrl: modrinthId + ? `https://modrinth.com/project/${modrinthId}/version/${version.id}` + : undefined, + }; + }); +} diff --git a/app/api/docs/search-index/route.ts b/app/api/docs/search-index/route.ts index db9bd607..4e133bea 100644 --- a/app/api/docs/search-index/route.ts +++ b/app/api/docs/search-index/route.ts @@ -1,11 +1,11 @@ -import fs from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; - import matter from "gray-matter"; import { NextResponse } from "next/server"; const MDX_EXTENSION_REGEX = /\.mdx$/; const TITLE_SPLIT_REGEX = /[-_]/; +const CACHE_TTL_MS = 10 * 60 * 1000; interface SearchIndexItem { title: string; @@ -21,6 +21,10 @@ const CATEGORY_LABELS: Record = { contribute: "Contribute", }; +let cache: SearchIndexItem[] | null = null; +let cacheTimestamp = 0; +let cachePromise: Promise | null = null; + function toTitleCase(value: string): string { return value .split(TITLE_SPLIT_REGEX) @@ -29,59 +33,91 @@ function toTitleCase(value: string): string { } function getCategoryLabel(relativePath: string): string { - const topLevel = relativePath.split(path.sep)[0] ?? "docs"; + const topLevel = relativePath.split("/")[0] ?? "docs"; return CATEGORY_LABELS[topLevel] ?? toTitleCase(topLevel); } -function findMarkdownFiles(dir: string): string[] { - const files: string[] = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...findMarkdownFiles(fullPath)); - } else if (entry.isFile() && entry.name.endsWith(".mdx")) { - files.push(fullPath); - } - } +async function findMarkdownFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return findMarkdownFiles(fullPath); + } + + if (entry.isFile() && entry.name.endsWith(".mdx")) { + return [fullPath]; + } - return files; + return []; + }) + ); + + return files.flat(); } -function generateSearchIndex() { +async function generateSearchIndex(): Promise { const docsDir = path.join(process.cwd(), "content/docs"); - const files = findMarkdownFiles(docsDir); - const searchIndex: SearchIndexItem[] = []; - - for (const file of files) { - const content = fs.readFileSync(file, "utf8"); - const { data, content: markdownContent } = matter(content); - const relativePath = path.relative(docsDir, file); - const urlPath = `/docs/${relativePath.replace(MDX_EXTENSION_REGEX, "")}`; - const category = getCategoryLabel(relativePath); - - const excerpt = markdownContent - .replace(/[#*`_~]/g, "") - .replace(/\n/g, " ") - .trim() - .substring(0, 150); - - searchIndex.push({ - title: data.title || path.basename(file, ".mdx"), - path: urlPath, - excerpt, - category, - }); - } + const files = await findMarkdownFiles(docsDir); + + const searchIndex = await Promise.all( + files.map(async (file) => { + const content = await fs.readFile(file, "utf8"); + const { data, content: markdownContent } = matter(content); + + const relativePath = path.relative(docsDir, file).split(path.sep).join("/"); + const urlPath = `/docs/${relativePath.replace(MDX_EXTENSION_REGEX, "")}`; + + const excerpt = markdownContent + .replace(/[#*`_~]/g, "") + .replace(/\n/g, " ") + .trim() + .substring(0, 150); + + return { + title: data.title || path.basename(file, ".mdx"), + path: urlPath, + excerpt, + category: getCategoryLabel(relativePath), + }; + }) + ); return searchIndex; } -export function GET() { +function getSearchIndexCached(): Promise { + const now = Date.now(); + + if (cache && now - cacheTimestamp < CACHE_TTL_MS) { + return Promise.resolve(cache); + } + + if (!cachePromise) { + cachePromise = generateSearchIndex() + .then((result) => { + cache = result; + cacheTimestamp = Date.now(); + return result; + }) + .finally(() => { + cachePromise = null; + }); + } + + return cachePromise; +} + +export async function GET() { try { - const searchIndex = generateSearchIndex(); - return NextResponse.json(searchIndex); + const searchIndex = await getSearchIndexCached(); + return NextResponse.json(searchIndex, { + headers: { + "Cache-Control": "public, s-maxage=600, stale-while-revalidate=86400", + }, + }); } catch (error) { console.error("Error generating search index:", error); return NextResponse.json({ error: "Failed to generate search index" }, { status: 500 }); diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index c0e0fc8f..a98ae329 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -1,16 +1,96 @@ import { ImageResponse } from "@takumi-rs/image-response"; import type { NextRequest } from "next/server"; +import { z } from "zod"; import { OgTemplate } from "@/components/og/og-template"; export const runtime = "nodejs"; +const DEFAULT_TITLE = "EternalCode.pl"; +const DEFAULT_SUBTITLE = "Open Source Solutions"; +const DEFAULT_IMAGE = "https://eternalcode.pl/logo.svg"; +const SITE_ORIGIN = "https://eternalcode.pl"; + +const ALLOWED_IMAGE_HOSTS = new Set([ + "eternalcode.pl", + "www.eternalcode.pl", + "github.com", + "avatars.githubusercontent.com", + "private-user-images.githubusercontent.com", + "i.imgur.com", + "imgur.com", + "cms.eternalcode.pl", +]); + +const OG_QUERY_SCHEMA = z.object({ + title: z.string().trim().min(1).max(120).optional(), + subtitle: z.string().trim().max(200).optional(), + image: z.string().trim().max(2048).optional(), +}); + +function sanitizeText(value: string): string { + let sanitized = ""; + + for (const character of value) { + const code = character.charCodeAt(0); + const isControlCode = code <= 31 || code === 127 || (code >= 128 && code <= 159); + if (!isControlCode) { + sanitized += character; + } + } + + return sanitized.trim(); +} + +function normalizeImageUrl(rawValue: string): string | null { + const value = rawValue.trim(); + if (!value) { + return null; + } + + let url: URL; + + if (value.startsWith("/")) { + url = new URL(value, SITE_ORIGIN); + } else { + try { + url = new URL(value); + } catch { + return null; + } + } + + if (url.protocol !== "https:") { + return null; + } + + if (!ALLOWED_IMAGE_HOSTS.has(url.hostname)) { + return null; + } + + return url.toString(); +} + export function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); + const parsedQuery = OG_QUERY_SCHEMA.safeParse(Object.fromEntries(searchParams.entries())); + + if (!parsedQuery.success) { + return Response.json({ error: "Invalid query parameters" }, { status: 400 }); + } + + const title = sanitizeText(parsedQuery.data.title ?? DEFAULT_TITLE) || DEFAULT_TITLE; + const subtitle = + sanitizeText(parsedQuery.data.subtitle ?? DEFAULT_SUBTITLE) || DEFAULT_SUBTITLE; - const title = searchParams.get("title") || "EternalCode.pl"; - const subtitle = searchParams.get("subtitle") || "Open Source Solutions"; - const image = searchParams.get("image") || "https://eternalcode.pl/logo.svg"; + let image = DEFAULT_IMAGE; + if (parsedQuery.data.image) { + const normalizedImage = normalizeImageUrl(parsedQuery.data.image); + if (!normalizedImage) { + return Response.json({ error: "Invalid image URL" }, { status: 400 }); + } + image = normalizedImage; + } return new ImageResponse(, { width: 1200, diff --git a/app/builds/page.tsx b/app/builds/page.tsx index caf5b598..ad4b649b 100644 --- a/app/builds/page.tsx +++ b/app/builds/page.tsx @@ -3,13 +3,13 @@ import { AlertCircle, Loader2 } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useState } from "react"; -import { fetchDevBuilds, fetchStableBuilds, PROJECTS, type Project } from "@/app/api/builds/builds"; import { BuildControls } from "@/components/builds/build-controls"; import { BuildHeader } from "@/components/builds/build-header"; import type { Build } from "@/components/builds/build-row"; import { BuildTable } from "@/components/builds/build-table"; import { Button } from "@/components/ui/button"; import { FacadePattern } from "@/components/ui/facade-pattern"; +import { type BuildTab, PROJECTS, type Project } from "@/lib/builds/projects"; function BuildExplorerContent() { const searchParams = useSearchParams(); @@ -19,11 +19,12 @@ function BuildExplorerContent() { const initialProject = PROJECTS.find((p) => p.id === projectIdParam) || PROJECTS[0]; const [activeProject, setActiveProject] = useState(initialProject); - const [activeTab, setActiveTab] = useState<"STABLE" | "DEV">("STABLE"); + const [activeTab, setActiveTab] = useState("STABLE"); const [builds, setBuilds] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastDownloadedId, setLastDownloadedId] = useState(null); + const [refreshNonce, setRefreshNonce] = useState(0); useEffect(() => { const foundProject = PROJECTS.find((p) => p.id === projectIdParam); @@ -53,46 +54,49 @@ function BuildExplorerContent() { ); const retryFetch = useCallback(() => { - setError(null); - setLoading(true); + setRefreshNonce((value) => value + 1); }, []); useEffect(() => { + const abortController = new AbortController(); + async function fetchData() { setLoading(true); setError(null); setBuilds([]); try { - const data = - activeTab === "STABLE" - ? await fetchStableBuilds(activeProject) - : await fetchDevBuilds(activeProject); - - setBuilds( - data.map((version) => { - const primaryFile = version.files.find((f) => f.primary) || version.files[0]; - return { - id: version.id, - name: version.name, - type: activeTab, - date: version.date_published, - downloadUrl: primaryFile?.url || "", - version: version.version_number, - runUrl: activeProject.modrinthId - ? `https://modrinth.com/project/${activeProject.modrinthId}/version/${version.id}` - : undefined, - }; - }) - ); + const params = new URLSearchParams({ + project: activeProject.id, + type: activeTab, + retry: String(refreshNonce), + }); + + const response = await fetch(`/api/builds?${params.toString()}`, { + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`Build endpoint returned ${response.status}`); + } + + const data = (await response.json()) as Build[]; + setBuilds(data); } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") { + return; + } console.error("Failed to load builds", e); setError("Failed to load builds. Please check your connection and try again."); } finally { - setLoading(false); + if (!abortController.signal.aborted) { + setLoading(false); + } } } + fetchData(); - }, [activeTab, activeProject]); + return () => abortController.abort(); + }, [activeTab, activeProject, refreshNonce]); return (
diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 00000000..ccff03c8 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,33 @@ +"use client"; + +interface AppErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function AppError({ error, reset }: AppErrorProps) { + return ( +
+
+

+ Something went wrong +

+

+ An unexpected error occurred while rendering this page. +

+ {process.env.NODE_ENV === "development" && ( +

{error.message}

+ )} +
+ +
+
+
+ ); +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 00000000..cacd3aad --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,35 @@ +"use client"; + +interface GlobalErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + return ( + + +
+
+

Critical application error

+

+ The app failed to render. You can retry or refresh the page. +

+ {process.env.NODE_ENV === "development" && ( +

{error.message}

+ )} +
+ +
+
+
+ + + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index a41d6fe6..12336a10 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,7 +6,6 @@ import "lenis/dist/lenis.css"; import type React from "react"; import "./prism-languages"; -import { CookieConsentModal } from "@/components/cookie-consent-modal"; import Footer from "@/components/footer/footer"; import Navbar from "@/components/hero/navbar"; import { OrganizationSchema } from "@/components/seo/organization-schema"; @@ -125,7 +124,6 @@ export default function RootLayout({