From 49476a414b8d261f085022d8a444ee7883bcb1d7 Mon Sep 17 00:00:00 2001 From: Priyanshu Bharti Date: Fri, 17 Apr 2026 17:44:40 +0000 Subject: [PATCH] Update server.js --- server.js | 426 +++++++++++++++++++++++++++++++++++++++++++++++++++++ server.mjs | 379 ----------------------------------------------- 2 files changed, 426 insertions(+), 379 deletions(-) create mode 100644 server.js delete mode 100644 server.mjs diff --git a/server.js b/server.js new file mode 100644 index 00000000..d0fa51b7 --- /dev/null +++ b/server.js @@ -0,0 +1,426 @@ +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(">", ">") +} diff --git a/server.mjs b/server.mjs deleted file mode 100644 index 0612614f..00000000 --- a/server.mjs +++ /dev/null @@ -1,379 +0,0 @@ -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}`) -})