import {createServer} from "node:http" import {createReadStream} from "node:fs" import {readFile, stat} from "node:fs/promises" import {dirname, extname, join, normalize} from "node:path" import {fileURLToPath} from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const buildDir = join(__dirname, "build") const indexPath = join(buildDir, "index.html") const indexHtml = await readFile(indexPath, "utf8").catch(error => { console.error("Unable to start server: build/index.html is missing. Run `pnpm run build` first.") throw error }) const defaults = { title: readMetaContent(indexHtml, "og:title") || "Flotilla", description: readMetaContent(indexHtml, "og:description") || readMetaContent(indexHtml, "description") || "Flotilla is nostr - for communities.", url: readMetaContent(indexHtml, "og:url") || "", image: readMetaContent(indexHtml, "twitter:image") || "/maskable-icon-512x512.png", } const relayInfoCache = new Map() const RELAY_CACHE_TTL_MS = 5 * 60 * 1000 const RELAY_TIMEOUT_MS = 1500 const MIME_TYPES = { ".css": "text/css; charset=utf-8", ".gif": "image/gif", ".html": "text/html; charset=utf-8", ".ico": "image/x-icon", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain; charset=utf-8", ".webmanifest": "application/manifest+json; charset=utf-8", ".woff": "font/woff", ".woff2": "font/woff2", } const server = createServer(async (req, res) => { try { const method = req.method || "GET" if (method !== "GET" && method !== "HEAD") { res.writeHead(405, {Allow: "GET, HEAD"}) res.end() return } const origin = getRequestOrigin(req) const requestUrl = new URL(req.url || "/", origin) const headOnly = method === "HEAD" if (requestUrl.pathname === "/healthz") { sendText(res, "ok", "text/plain; charset=utf-8", headOnly) return } if (isJoinInvitePath(requestUrl.pathname)) { const html = await renderInvitePage(requestUrl, origin) sendText(res, html, "text/html; charset=utf-8", headOnly) return } const filePath = resolveStaticPath(requestUrl.pathname) if (filePath) { try { const fileInfo = await stat(filePath) if (fileInfo.isFile()) { sendFile(res, filePath, headOnly) return } } catch { // Fall through to SPA index fallback. } } sendText(res, indexHtml, "text/html; charset=utf-8", headOnly) } catch { res.writeHead(500, {"Content-Type": "text/plain; charset=utf-8"}) res.end("Internal Server Error") } }) const port = Number.parseInt(process.env.PORT || "3000", 10) const host = process.env.HOST || "0.0.0.0" server.listen(port, host, () => { console.log(`Flotilla server listening on http://${host}:${port}`) }) function resolveStaticPath(pathname) { const decodedPath = decodeURIComponent(pathname) if (decodedPath.includes("\0")) { return undefined } const relativePath = decodedPath.replace(/^\/+/, "") const normalizedPath = normalize(relativePath) if (normalizedPath.startsWith("..")) { return undefined } return join(buildDir, normalizedPath) } function sendText(res, body, contentType, headOnly) { res.writeHead(200, { "Cache-Control": contentType.includes("text/html") ? "no-cache" : "public, max-age=60", "Content-Type": contentType, }) if (headOnly) { res.end() return } res.end(body) } function sendFile(res, filePath, headOnly) { const extension = extname(filePath).toLowerCase() res.writeHead(200, { "Cache-Control": extension === ".html" ? "no-cache" : "public, max-age=31536000, immutable", "Content-Type": MIME_TYPES[extension] || "application/octet-stream", }) if (headOnly) { res.end() return } const stream = createReadStream(filePath) stream.on("error", () => { if (!res.headersSent) { res.writeHead(500, {"Content-Type": "text/plain; charset=utf-8"}) res.end("Internal Server Error") return } res.destroy() }) stream.pipe(res) } function isJoinInvitePath(pathname) { return pathname === "/join" || pathname === "/join/" } async function renderInvitePage(requestUrl, origin) { const relayUrl = parseInviteRelay(requestUrl) if (!relayUrl) { return indexHtml } const relayInfo = await loadRelayInfo(relayUrl) const relayDisplay = getRelayDisplay(relayUrl) const platformName = defaults.title const image = toAbsoluteHttpUrl(defaults.image, origin) || `${origin}/maskable-icon-512x512.png` const title = relayInfo?.name ? `Invitation to join ${relayInfo.name} on ${platformName}` : `Invitation to join a ${platformName} space` const description = relayInfo?.description || `Join this ${platformName} space on ${relayDisplay}.` const meta = { card: "summary", description, image, site: defaults.url || origin, title, type: "website", url: requestUrl.href, } return setInviteMeta(indexHtml, meta) } function parseInviteRelay(requestUrl) { const relay = requestUrl.searchParams.get("r") || requestUrl.searchParams.get("relay") if (!relay) { return undefined } return normalizeInviteRelay(relay) } function normalizeInviteRelay(value) { const trimmed = value.trim() if (!trimmed) { return undefined } const hasProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed) const normalized = hasProtocol ? trimmed : `wss://${trimmed}` try { const relayUrl = new URL(normalized) if (relayUrl.protocol === "http:") { relayUrl.protocol = "ws:" } if (relayUrl.protocol === "https:") { relayUrl.protocol = "wss:" } if (!["ws:", "wss:"].includes(relayUrl.protocol)) { return undefined } relayUrl.hash = "" relayUrl.search = "" return relayUrl.href.replace(/\/$/, "") } catch { return undefined } } async function loadRelayInfo(relayUrl) { const now = Date.now() const cached = relayInfoCache.get(relayUrl) if (cached && cached.expiresAt > now) { return cached.value } const relayHttpUrl = toRelayHttpUrl(relayUrl) if (!relayHttpUrl) { return undefined } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS) let relayInfo try { const response = await fetch(relayHttpUrl, { headers: {Accept: "application/nostr+json"}, signal: controller.signal, }) if (response.ok) { const json = await response.json() const name = typeof json.name === "string" ? json.name.trim() : "" const description = typeof json.description === "string" ? json.description.trim() : "" const icon = typeof json.icon === "string" ? toAbsoluteHttpUrl(json.icon, relayHttpUrl) : undefined relayInfo = { description: description || undefined, icon, name: name || undefined, } } } catch { relayInfo = undefined } finally { clearTimeout(timeout) } relayInfoCache.set(relayUrl, { expiresAt: now + RELAY_CACHE_TTL_MS, value: relayInfo, }) return relayInfo } function toRelayHttpUrl(relayUrl) { if (relayUrl.startsWith("wss://")) { return `https://${relayUrl.slice(6)}` } if (relayUrl.startsWith("ws://")) { return `http://${relayUrl.slice(5)}` } return undefined } function getRelayDisplay(relayUrl) { const relayHttpUrl = toRelayHttpUrl(relayUrl) if (!relayHttpUrl) { return relayUrl } try { return new URL(relayHttpUrl).host } catch { return relayUrl } } function toAbsoluteHttpUrl(value, baseUrl) { try { const parsed = new URL(value, baseUrl) if (["http:", "https:"].includes(parsed.protocol)) { return parsed.href } return undefined } catch { return undefined } } function setInviteMeta(html, meta) { let result = html result = upsertMetaTag(result, "name", "description", meta.description) result = upsertMetaTag(result, "name", "og:url", meta.url) result = upsertMetaTag(result, "name", "og:type", meta.type) result = upsertMetaTag(result, "name", "og:title", meta.title) result = upsertMetaTag(result, "name", "og:description", meta.description) result = upsertMetaTag(result, "name", "twitter:card", meta.card) result = upsertMetaTag(result, "name", "twitter:site", meta.site) result = upsertMetaTag(result, "name", "twitter:title", meta.title) result = upsertMetaTag(result, "name", "twitter:description", meta.description) result = upsertMetaTag(result, "name", "twitter:image", meta.image) result = upsertMetaTag(result, "property", "og:url", meta.url) result = upsertMetaTag(result, "property", "og:type", meta.type) result = upsertMetaTag(result, "property", "og:title", meta.title) result = upsertMetaTag(result, "property", "og:description", meta.description) result = upsertMetaTag(result, "property", "og:image", meta.image) return result } function upsertMetaTag(html, attribute, key, value) { const escapedKey = escapeRegExp(key) const escapedValue = escapeHtml(value) const pattern = new RegExp( `(]*${attribute}=["']${escapedKey}["'][^>]*content=["'])[^"']*(["'][^>]*>)`, "i", ) if (pattern.test(html)) { return html.replace(pattern, `$1${escapedValue}$2`) } return html.replace( "", ` \n `, ) } function readMetaContent(html, key) { const escapedKey = escapeRegExp(key) const pattern = new RegExp( `]*(?:name|property)=["']${escapedKey}["'][^>]*content=["']([^"']*)["'][^>]*>`, "i", ) const match = html.match(pattern) return match ? match[1] : undefined } function getRequestOrigin(req) { const forwardedProto = req.headers["x-forwarded-proto"] const forwardedHost = req.headers["x-forwarded-host"] const protocol = firstHeaderValue(forwardedProto) || "http" const host = firstHeaderValue(forwardedHost) || req.headers.host || "localhost" return `${protocol}://${host}` } function firstHeaderValue(value) { if (Array.isArray(value)) { return value[0]?.split(",")[0]?.trim() } if (typeof value === "string") { return value.split(",")[0].trim() } return undefined } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("\"", """) .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">") }