From ff5bd8b09220c4b7cc7faaaad4d19d1022ddedb6 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Fri, 17 Apr 2026 23:03:48 +0530 Subject: [PATCH] feat: improve og invite preview --- Dockerfile | 3 +- README.md | 2 +- package.json | 1 + server.mjs | 379 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 server.mjs diff --git a/Dockerfile b/Dockerfile index 780ce934..013e7632 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,5 +28,6 @@ WORKDIR /app # Copy only the built output - no source, no .env, no dev deps COPY --from=builder /app/build ./build +COPY --from=builder /app/server.mjs ./server.mjs -CMD ["npx", "serve", "-s", "build"] +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md index 6092fd32..8eacb8d9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ To run your own Flotilla, it's as simple as: ```sh pnpm install pnpm run build -npx serve -s build +pnpm run start ``` Or, if you prefer to use a container: diff --git a/package.json b/package.json index b0dc3763..ae3489ad 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite dev", "build": "./build.sh", + "start": "node server.mjs", "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "tauri:dev": "tauri dev", "tauri:build": "tauri build", diff --git a/server.mjs b/server.mjs new file mode 100644 index 00000000..0612614f --- /dev/null +++ b/server.mjs @@ -0,0 +1,379 @@ +import http from "node:http" +import path from "node:path" +import {promises as fs} from "node:fs" + +const PORT = Number(process.env.PORT || 3000) +const BUILD_DIR = path.resolve("build") +const INDEX_HTML_PATH = path.join(BUILD_DIR, "index.html") +const DEFAULT_IMAGE_PATH = "/maskable-icon-512x512.png" +const CACHE_SECONDS = 60 + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".txt": "text/plain; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + +const BOT_UA_REGEX = + /bot|crawler|spider|slackbot|telegrambot|twitterbot|facebookexternalhit|discordbot|linkedinbot|whatsapp|skypeuripreview|applebot|googlebot/i + +const relayMetadataCache = new Map() + +const htmlEscape = value => + String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") + +const isBotRequest = req => BOT_UA_REGEX.test(String(req.headers["user-agent"] || "")) + +const relayToHttpUrl = relay => { + try { + const url = new URL(relay) + + if (url.protocol === "wss:") { + url.protocol = "https:" + } else if (url.protocol === "ws:") { + url.protocol = "http:" + } + + return url.toString() + } catch { + return null + } +} + +const normalizeRelayParam = relayParam => { + const relay = String(relayParam || "").trim() + + if (!relay) { + return null + } + + try { + const parsed = new URL(relay.includes("://") ? relay : `wss://${relay}`) + + if (!["ws:", "wss:", "http:", "https:"].includes(parsed.protocol)) { + return null + } + + if (parsed.protocol === "http:") { + parsed.protocol = "ws:" + } + + if (parsed.protocol === "https:") { + parsed.protocol = "wss:" + } + + return parsed.toString() + } catch { + return null + } +} + +const fetchJsonWithTimeout = async (url, timeoutMs = 5000) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { + method: "GET", + headers: {Accept: "application/nostr+json, application/json"}, + signal: controller.signal, + }) + + if (!response.ok) { + return null + } + + return await response.json() + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +const getRelayMetadata = async relayUrl => { + if (!relayUrl) { + return null + } + + const now = Date.now() + const cached = relayMetadataCache.get(relayUrl) + + if (cached && now - cached.at < CACHE_SECONDS * 1000) { + return cached.value + } + + const httpUrl = relayToHttpUrl(relayUrl) + + if (!httpUrl) { + return null + } + + const profile = await fetchJsonWithTimeout(httpUrl) + + const value = { + name: profile?.name || profile?.title || null, + icon: profile?.icon || profile?.picture || null, + } + + relayMetadataCache.set(relayUrl, {at: now, value}) + + return value +} + +const getAbsoluteUrl = req => { + const host = req.headers["x-forwarded-host"] || req.headers.host || `localhost:${PORT}` + const protocolHeader = req.headers["x-forwarded-proto"] + const protocol = String(protocolHeader || "").split(",")[0] || "http" + + return new URL(req.url || "/", `${protocol}://${host}`) +} + +const getLocalRequestUrl = req => { + const host = req.headers.host || `localhost:${PORT}` + + return new URL(req.url || "/", `http://${host}`) +} + +const toAbsoluteImageUrl = (url, origin) => { + try { + return new URL(url, origin).toString() + } catch { + return new URL(DEFAULT_IMAGE_PATH, origin).toString() + } +} + +const escapeRegex = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const replaceOrInsertMeta = (html, keyValue, content, preferredKey = "name") => { + const escapedValue = escapeRegex(keyValue) + const tag = `` + const pattern = new RegExp(`]*(?:name|property)=["']${escapedValue}["'][^>]*>`, "i") + + if (pattern.test(html)) { + return html.replace(pattern, tag) + } + + return html.replace("", ` ${tag}\n`) +} + +const replaceOrInsertTitle = (html, title) => { + const tag = `${htmlEscape(title)}` + + if (/]*>.*<\/title>/i.test(html)) { + return html.replace(/]*>.*<\/title>/i, tag) + } + + return html.replace("", ` ${tag}\n`) +} + +const replaceOrInsertCanonical = (html, href) => { + const tag = `` + const pattern = /]*rel=["']canonical["'][^>]*>/i + + if (pattern.test(html)) { + return html.replace(pattern, tag) + } + + return html.replace("", ` ${tag}\n`) +} + +const injectInviteMetadata = (indexHtml, {title, description, image, ogUrl}) => { + let html = indexHtml + + html = replaceOrInsertTitle(html, title) + html = replaceOrInsertMeta(html, "description", description, "name") + html = replaceOrInsertMeta(html, "og:title", title, "property") + html = replaceOrInsertMeta(html, "og:description", description, "property") + html = replaceOrInsertMeta(html, "og:image", image, "property") + html = replaceOrInsertMeta(html, "og:url", ogUrl, "property") + html = replaceOrInsertMeta(html, "og:type", "website", "property") + html = replaceOrInsertMeta(html, "twitter:card", "summary_large_image", "name") + html = replaceOrInsertMeta(html, "twitter:title", title, "name") + html = replaceOrInsertMeta(html, "twitter:description", description, "name") + html = replaceOrInsertMeta(html, "twitter:image", image, "name") + html = replaceOrInsertCanonical(html, ogUrl) + + return html +} + +const loadIndexTemplate = async () => { + try { + return await fs.readFile(INDEX_HTML_PATH, "utf-8") + } catch { + return [ + "", + '', + "", + ' ', + ' ', + "", + "", + "
", + "", + "", + ].join("\n") + } +} + +const getSpaceMetadata = async (r, c) => { + const relay = String(r || "").trim() + const claim = String(c || "").trim() + + let name = "Space Name" + + if (relay && claim) { + name = `Space (${claim})` + } else if (claim) { + name = `Space (${claim})` + } else if (relay) { + name = `Space (${relay})` + } + + return { + name, + icon: null, + } +} + +const send = (res, statusCode, body, contentType = "text/plain; charset=utf-8") => { + res.writeHead(statusCode, { + "Content-Type": contentType, + "Cache-Control": "no-store", + }) + res.end(body) +} + +const redirect = (res, location, statusCode = 302) => { + res.writeHead(statusCode, { + Location: location, + "Cache-Control": "no-store", + }) + res.end() +} + +const fileExists = async filePath => { + try { + const stat = await fs.stat(filePath) + + return stat.isFile() + } catch { + return false + } +} + +const serveIndexOrFallback = async res => { + if (await fileExists(INDEX_HTML_PATH)) { + await serveFile(res, INDEX_HTML_PATH) + return + } + + const fallbackHtml = await loadIndexTemplate() + send(res, 200, fallbackHtml, "text/html; charset=utf-8") +} + +const serveFile = async (res, filePath) => { + try { + const data = await fs.readFile(filePath) + const ext = path.extname(filePath).toLowerCase() + const contentType = MIME_TYPES[ext] || "application/octet-stream" + + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable", + }) + res.end(data) + } catch { + send(res, 404, "Not Found") + } +} + +const sanitizeRequestedPath = pathname => { + const safePath = path.normalize(pathname).replace(/^\.+\//, "") + const candidatePath = path.join(BUILD_DIR, safePath) + + if (!candidatePath.startsWith(BUILD_DIR)) { + return null + } + + return candidatePath +} + +const server = http.createServer(async (req, res) => { + try { + const method = req.method || "GET" + + if (!["GET", "HEAD"].includes(method)) { + send(res, 405, "Method Not Allowed") + return + } + + const requestUrl = getAbsoluteUrl(req) + const pathname = decodeURIComponent(requestUrl.pathname) + + if (pathname.startsWith("/join")) { + const url = getLocalRequestUrl(req) + const r = url.searchParams.get("r") + const c = url.searchParams.get("c") + const space = await getSpaceMetadata(r, c) + const spaceName = space?.name || "Space Name" + const title = `Invitation to ${spaceName} • Flotilla` + const description = `Join ${spaceName} on Flotilla - a Nostr community space` + const image = toAbsoluteImageUrl(space?.icon || DEFAULT_IMAGE_PATH, requestUrl.origin) + const indexHtml = await loadIndexTemplate() + const ogHtml = injectInviteMetadata(indexHtml, { + title, + description, + image, + ogUrl: requestUrl.toString(), + }) + + if (isBotRequest(req)) { + send(res, 200, ogHtml, "text/html; charset=utf-8") + return + } + + await serveIndexOrFallback(res) + return + } + + const directFilePath = sanitizeRequestedPath(pathname) + + if (!directFilePath) { + send(res, 400, "Bad Request") + return + } + + const stat = await fs.stat(directFilePath).catch(() => null) + + if (stat?.isFile()) { + await serveFile(res, directFilePath) + return + } + + await serveIndexOrFallback(res) + } catch { + send(res, 500, "Internal Server Error") + } +}) + +server.listen(PORT, () => { + console.log(`Flotilla server running on port ${PORT}`) +})