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}`) })