diff --git a/backend/src/embeds.ts b/backend/src/embeds.ts new file mode 100644 index 0000000..4231636 --- /dev/null +++ b/backend/src/embeds.ts @@ -0,0 +1,156 @@ +import { getVideoById } from "./services/db"; +import { getStreamUrl, getObjectUrl } from "./services/rustfs"; +import type { VideoEntry } from "./types/video"; +import { readFileSync } from "fs"; + +const BASE_URL = process.env.BASE_URL || "https://surf.nathan.rip"; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function formatRunTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toFixed(3).padStart(6, "0")}`; +} + +function mapDisplayName(mapName: string): string { + return mapName.replace(/_/g, " ").toUpperCase(); +} + +function pageTitle(video: VideoEntry): string { + return `${mapDisplayName(video.mapName)} — ${video.playerName} · surf.nathan.rip`; +} + +function ogTitle(video: VideoEntry): string { + return mapDisplayName(video.mapName); +} + +function ogDescription(video: VideoEntry): string { + const parts: string[] = [formatRunTime(video.runTime)]; + if (video.tier != null) parts.push(`Tier ${video.tier}`); + return `${video.playerName} on ${mapDisplayName(video.mapName)} · ${parts.join(" · ")}`; +} + +function buildOgsHtml(video: VideoEntry): string { + const runUrl = `${BASE_URL}/run/${video.id}`; + const title = ogTitle(video); + const description = ogDescription(video); + const oembedUrl = `${BASE_URL}/api/oembed/${video.id}`; + + const thumbnailUrl = video.thumbnailKey + ? getObjectUrl(video.thumbnailKey) + : undefined; + const videoStreamUrl = `${BASE_URL}${getStreamUrl(video.id)}`; + + let tags = + // oEmbed discovery — lets Discord (and others) find the JSON endpoint + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` `; + + if (thumbnailUrl) { + tags += + `\n \n` + + ` \n` + + ` `; + } + + // Video embed — Discord & Facebook + tags += + `\n \n` + + ` \n` + + ` \n` + + ` `; + + // Twitter / X + tags += + `\n \n` + + ` \n` + + ` `; + + if (thumbnailUrl) { + tags += `\n `; + } + + // Steam uses the same og: tags, nothing extra needed + + return tags; +} + +export interface OembedResponse { + type: string; + version: string; + title: string; + author_name: string; + author_url: string; + provider_name: string; + provider_url: string; + thumbnail_url?: string; + html?: string; +} + +export function getOembed(id: string): OembedResponse | null { + const video = getVideoById(id); + if (!video) return null; + + const thumbnailUrl = video.thumbnailKey + ? getObjectUrl(video.thumbnailKey) + : undefined; + + return { + type: "video", + version: "1.0", + title: ogTitle(video), + author_name: video.playerName, + author_url: `https://dashboard.momentum-mod.org/maps/${video.mapName}`, + provider_name: "surf.nathan.rip", + provider_url: BASE_URL, + thumbnail_url: thumbnailUrl, + }; +} + +/** + * For a given run ID, return the SPA's index.html with OG meta tags + * injected into and the updated. If the run doesn't exist, + * returns the plain SPA html (React Router will show a 404). If the + * static index.html isn't available, returns null. + */ +export function renderRunPage( + id: string, + staticDir: string, +): { html: string; contentType: string } | null { + const indexPath = `${staticDir}/index.html`; + + let indexHtml: string; + try { + indexHtml = readFileSync(indexPath, "utf-8"); + } catch { + return null; + } + + const video = getVideoById(id); + if (!video) { + // Serve the plain SPA — React Router handles the 404 + return { html: indexHtml, contentType: "text/html" }; + } + + const ogTags = buildOgsHtml(video); + const title = pageTitle(video); + const enhancedHtml = indexHtml + .replace("</head>", `${ogTags}\n </head>`) + .replace( + /<title>[^<]*<\/title>/, + `<title>${escapeHtml(title)}`, + ); + + return { html: enhancedHtml, contentType: "text/html" }; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 1bf75e1..8679d45 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,8 +2,7 @@ import { Elysia, status } from "elysia"; import { cors } from "@elysiajs/cors"; import { videoRoutes } from "./routes/videos"; import { initDb } from "./services/db"; -import { join } from "path"; -import { existsSync, statSync } from "fs"; +import { renderRunPage, getOembed } from "./embeds"; initDb(); @@ -16,28 +15,20 @@ const app = new Elysia() exposeHeaders: ["Content-Range", "Accept-Ranges", "Content-Length"], }), ) - .onError(({ error }) => { - console.error("Unhandled error:", error); - return status(500, { error: "Internal server error" }); - }) .use(videoRoutes) .get("/api/health", () => ({ status: "ok" })) - .get("*", ({ request }) => { - const url = new URL(request.url); - const pathname = url.pathname; - - if (pathname.startsWith("/api")) return status(404, "Not found"); - - const filePath = join(STATIC_DIR, pathname); - if (existsSync(filePath) && statSync(filePath).isFile()) - return Bun.file(filePath); - - const indexPath = join(STATIC_DIR, "index.html"); - if (existsSync(indexPath)) return Bun.file(indexPath); - - return status(404, "Not found"); + .get("/api/oembed/:id", ({ params: { id } }) => { + const oembed = getOembed(id); + if (!oembed) return status(404, { error: "Not found" }); + return oembed; + }) + .get("/run/:id", ({ params: { id } }) => { + const result = renderRunPage(id, STATIC_DIR); + if (!result) return status(404, "Not found"); + return new Response(result.html, { + headers: { "Content-Type": result.contentType }, + }); }) .listen(PORT); console.log(`surf.nathan.rip running on port ${PORT}`); -console.log(`Serving static files from ${STATIC_DIR}`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 013acb4..f732cbd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ export default function App() { }> } /> - } /> + } /> } /> diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 8dfac79..c3eef05 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -52,7 +52,7 @@ export default function VideoCard({ video }: { video: VideoListItem }) { return (
diff --git a/frontend/src/pages/VideoDetail.tsx b/frontend/src/pages/VideoDetail.tsx index 8aea319..7a480b5 100644 --- a/frontend/src/pages/VideoDetail.tsx +++ b/frontend/src/pages/VideoDetail.tsx @@ -66,8 +66,14 @@ export default function VideoDetail() { useEffect(() => { if (!id) return; fetchVideo(id) - .then(setVideo) + .then((v) => { + setVideo(v); + document.title = `${getMapDisplayName(v.mapName)} — ${v.playerName} · surf.nathan.rip`; + }) .catch((err) => setError(err.message)); + return () => { + document.title = "surf.nathan.rip"; + }; }, [id]); if (error) {