import http from "node:http" import path from "node:path" import {createReadStream} from "node:fs" import {promises as fs} from "node:fs" import {fileURLToPath} from "node:url" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const BUILD_DIR = path.join(__dirname, "build") const INDEX_PATH = path.join(BUILD_DIR, "index.html") const readPositiveInt = (value, fallback) => { const parsed = Number.parseInt(value || "", 10) return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } const PORT = readPositiveInt(process.env.PORT, 3000) const HOST = process.env.HOST || "0.0.0.0" const REQUEST_TIMEOUT_MS = readPositiveInt(process.env.INVITE_PREVIEW_TIMEOUT_MS, 1200) const CACHE_MAX_ITEMS = readPositiveInt(process.env.INVITE_PREVIEW_CACHE_MAX, 500) const POSITIVE_CACHE_TTL_MS = readPositiveInt( process.env.INVITE_PREVIEW_CACHE_TTL_MS, 15 * 60 * 1000, ) const NEGATIVE_CACHE_TTL_MS = readPositiveInt( process.env.INVITE_PREVIEW_NEGATIVE_CACHE_TTL_MS, 2 * 60 * 1000, ) const MIME_TYPES = Object.freeze({ ".avif": "image/avif", ".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", ".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain; charset=utf-8", ".webmanifest": "application/manifest+json; charset=utf-8", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2", }) const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const escapeHtml = value => value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'") const truncate = (value, limit) => { if (value.length <= limit) { return value } if (limit <= 3) { return value.slice(0, limit) } return `${value.slice(0, limit - 3)}...` } const sanitizeText = (value, limit) => { if (typeof value !== "string") { return undefined } const compact = value.replace(/\s+/g, " ").trim() if (!compact) { return undefined } return truncate(compact, limit) } const isRecord = value => Boolean(value) && typeof value === "object" && !Array.isArray(value) const readMetaContent = (html, key) => { const pattern = new RegExp( `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`, "i", ) const match = html.match(pattern) return match?.[1] } const upsertTitle = (html, title) => { const escapedTitle = escapeHtml(title) const pattern = /]*>.*?<\/title>/is if (pattern.test(html)) { return html.replace(pattern, `${escapedTitle}`) } return html.replace("", ` ${escapedTitle}\n `) } const upsertMetaTag = (html, key, content, attribute) => { const pattern = new RegExp( `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`, "i", ) const tag = `` if (pattern.test(html)) { return html.replace(pattern, tag) } return html.replace("", ` ${tag}\n `) } const upsertCanonical = (html, href) => { const pattern = /]*rel=["']canonical["'][^>]*>/i const tag = `` if (pattern.test(html)) { return html.replace(pattern, tag) } return html.replace("", ` ${tag}\n `) } const normalizeRelayInput = value => { const trimmed = value.trim() if (!trimmed) { return undefined } const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `wss://${trimmed}` try { const relayUrl = new URL(withScheme) if (!["ws:", "wss:", "http:", "https:"].includes(relayUrl.protocol)) { return undefined } relayUrl.hash = "" relayUrl.search = "" if (relayUrl.pathname !== "/") { relayUrl.pathname = relayUrl.pathname.replace(/\/+$/, "") } return relayUrl.toString() } catch { return undefined } } const relayToInfoUrl = relayUrl => { try { const relayHttpUrl = new URL(relayUrl) if (relayHttpUrl.protocol === "ws:") relayHttpUrl.protocol = "http:" if (relayHttpUrl.protocol === "wss:") relayHttpUrl.protocol = "https:" if (!["http:", "https:"].includes(relayHttpUrl.protocol)) { return undefined } relayHttpUrl.hash = "" relayHttpUrl.search = "" return relayHttpUrl.toString() } catch { return undefined } } const normalizeImageUrl = (value, baseUrl) => { if (typeof value !== "string") { return undefined } const trimmed = value.trim() if (!trimmed) { return undefined } try { const imageUrl = new URL(trimmed, baseUrl) if (!["http:", "https:"].includes(imageUrl.protocol)) { return undefined } return imageUrl.toString() } catch { return undefined } } const decodeHtmlEntities = value => value .replaceAll("&", "&") .replaceAll(""", '"') .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">") const readHtmlAttribute = (tag, key) => { const pattern = new RegExp(`${escapeRegExp(key)}=["']([^"']*)["']`, "i") const match = tag.match(pattern) return match?.[1] } const readHtmlTagContent = (html, tag) => { const pattern = new RegExp(`<${escapeRegExp(tag)}[^>]*>([\\s\\S]*?)<\\/${escapeRegExp(tag)}>`, "i") const match = html.match(pattern) return match?.[1] } const readHtmlMetaContent = (html, key) => { const forwardPattern = new RegExp( `]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`, "i", ) const reversePattern = new RegExp( `]*content=["']([^"']*)["'][^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`, "i", ) return html.match(forwardPattern)?.[1] || html.match(reversePattern)?.[1] } const readHtmlIconHref = html => { const links = html.match(/]*>/gi) || [] for (const link of links) { const rel = readHtmlAttribute(link, "rel")?.toLowerCase() || "" if (!rel.includes("icon")) { continue } const href = readHtmlAttribute(link, "href") if (href) { return href } } } const requestOrigin = request => { const forwardedProto = request.headers["x-forwarded-proto"] const forwardedHost = request.headers["x-forwarded-host"] const host = (typeof forwardedHost === "string" && forwardedHost.split(",")[0].trim()) || request.headers.host || "localhost" const protocol = (typeof forwardedProto === "string" && forwardedProto.split(",")[0].trim()) || (request.socket.encrypted ? "https" : "http") return `${protocol}://${host}` } const absoluteUrlFromRequest = (requestUrl, value) => { try { return new URL(value, `${requestUrl.protocol}//${requestUrl.host}`).toString() } catch { return value } } const safeBuildPath = pathname => { let decodedPath = pathname try { decodedPath = decodeURIComponent(pathname) } catch { return undefined } const normalizedPath = path.posix.normalize(decodedPath) const routePath = normalizedPath === "/" ? "/index.html" : normalizedPath const resolvedPath = path.resolve(BUILD_DIR, `.${routePath}`) if (!resolvedPath.startsWith(BUILD_DIR + path.sep) && resolvedPath !== BUILD_DIR) { return undefined } return resolvedPath } const findStaticFile = async pathname => { const candidatePath = safeBuildPath(pathname) if (!candidatePath) { return undefined } try { const stats = await fs.stat(candidatePath) if (stats.isFile()) { return {filePath: candidatePath, size: stats.size} } if (stats.isDirectory()) { const nestedIndex = path.join(candidatePath, "index.html") const indexStats = await fs.stat(nestedIndex) if (indexStats.isFile()) { return {filePath: nestedIndex, size: indexStats.size} } } } catch { return undefined } return undefined } const cacheByRelay = new Map() const inFlightByRelay = new Map() const getCachedRelayData = relayUrl => { const cached = cacheByRelay.get(relayUrl) if (cached === undefined) { return undefined } if (cached.expiresAt <= Date.now()) { cacheByRelay.delete(relayUrl) return undefined } cacheByRelay.delete(relayUrl) cacheByRelay.set(relayUrl, cached) return cached.value } const setCachedRelayData = (relayUrl, value, ttlMs) => { if (cacheByRelay.size >= CACHE_MAX_ITEMS) { const oldestKey = cacheByRelay.keys().next().value if (oldestKey !== undefined) { cacheByRelay.delete(oldestKey) } } cacheByRelay.set(relayUrl, { expiresAt: Date.now() + ttlMs, value, }) } const fetchRelayMetadata = async relayUrl => { const cached = getCachedRelayData(relayUrl) if (cached !== undefined) { return cached } const inFlight = inFlightByRelay.get(relayUrl) if (inFlight !== undefined) { return inFlight } const loader = (async () => { const infoUrl = relayToInfoUrl(relayUrl) if (!infoUrl) { const empty = {} setCachedRelayData(relayUrl, empty, NEGATIVE_CACHE_TTL_MS) return empty } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) let metadata = {} try { const response = await fetch(infoUrl, { method: "GET", headers: { Accept: "application/nostr+json, application/json;q=0.9, */*;q=0.1", }, redirect: "follow", signal: controller.signal, }) if (response.ok) { const text = await response.text() let payload try { const parsed = JSON.parse(text) payload = isRecord(parsed) ? parsed : undefined } catch { payload = undefined } const jsonName = sanitizeText(payload?.name || payload?.title, 80) const jsonDescription = sanitizeText(payload?.description, 180) const jsonIcon = normalizeImageUrl(payload?.icon || payload?.picture || payload?.image, infoUrl) metadata = { ...(jsonName ? {name: jsonName} : {}), ...(jsonDescription ? {description: jsonDescription} : {}), ...(jsonIcon ? {icon: jsonIcon} : {}), } if (Object.keys(metadata).length === 0) { const htmlName = sanitizeText(decodeHtmlEntities(readHtmlTagContent(text, "title") || ""), 80) const htmlDescription = sanitizeText( decodeHtmlEntities(readHtmlMetaContent(text, "description") || ""), 180, ) const htmlIcon = normalizeImageUrl( decodeHtmlEntities( readHtmlMetaContent(text, "og:image") || readHtmlMetaContent(text, "twitter:image") || readHtmlIconHref(text) || "", ), infoUrl, ) metadata = { ...(htmlName ? {name: htmlName} : {}), ...(htmlDescription ? {description: htmlDescription} : {}), ...(htmlIcon ? {icon: htmlIcon} : {}), } } } } catch { metadata = {} } finally { clearTimeout(timeout) } setCachedRelayData( relayUrl, metadata, Object.keys(metadata).length > 0 ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS, ) return metadata })() inFlightByRelay.set(relayUrl, loader) try { return await loader } finally { inFlightByRelay.delete(relayUrl) } } const isJoinInvitePath = pathname => pathname === "/join" || pathname === "/join/" const parseInvite = requestUrl => { const relayParam = requestUrl.searchParams.get("r") || "" const relayUrl = normalizeRelayInput(relayParam) if (!relayUrl) { return undefined } const claim = sanitizeText(requestUrl.searchParams.get("c") || "", 256) || "" return {relayUrl, claim} } const loadIndexTemplate = async () => { try { return await fs.readFile(INDEX_PATH, "utf8") } catch (error) { console.error(`Unable to read ${INDEX_PATH}. Run \"pnpm run build\" first.`) throw error } } const INDEX_TEMPLATE = await loadIndexTemplate() const DEFAULT_PLATFORM_NAME = sanitizeText(process.env.VITE_PLATFORM_NAME, 80) || sanitizeText(readMetaContent(INDEX_TEMPLATE, "og:title"), 80) || sanitizeText(readMetaContent(INDEX_TEMPLATE, "twitter:title"), 80) || "Flotilla" const DEFAULT_PLATFORM_DESCRIPTION = sanitizeText(process.env.VITE_PLATFORM_DESCRIPTION, 180) || sanitizeText(readMetaContent(INDEX_TEMPLATE, "description"), 180) || "Flotilla is nostr - for communities." const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => { const parts = [] if (spaceName) { parts.push(`You are invited to join ${spaceName} on ${DEFAULT_PLATFORM_NAME}.`) } else { parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`) } if (relayHost) { parts.push(`Relay: ${relayHost}.`) } if (relayDescription) { parts.push(relayDescription) } else { parts.push(DEFAULT_PLATFORM_DESCRIPTION) } return sanitizeText(parts.join(" "), 220) || DEFAULT_PLATFORM_DESCRIPTION } const buildInviteMeta = (requestUrl, invite, relayMetadata) => { let relayHost = "" try { relayHost = new URL(invite.relayUrl).host } catch { relayHost = "" } const spaceName = sanitizeText(relayMetadata.name, 80) const relayDescription = sanitizeText(relayMetadata.description, 180) const title = spaceName ? `Invite to ${spaceName} on ${DEFAULT_PLATFORM_NAME}` : `Invite to a Space on ${DEFAULT_PLATFORM_NAME}` const description = buildInviteDescription({spaceName, relayHost, relayDescription}) const image = relayMetadata.icon || absoluteUrlFromRequest(requestUrl, "/maskable-icon-512x512.png") const url = requestUrl.toString() const site = `${requestUrl.protocol}//${requestUrl.host}` return {title, description, image, url, site} } const injectInviteMeta = (html, metadata) => { let output = html output = upsertTitle(output, metadata.title) output = upsertCanonical(output, metadata.url) output = upsertMetaTag(output, "description", metadata.description, "name") output = upsertMetaTag(output, "og:type", "website", "property") output = upsertMetaTag(output, "og:url", metadata.url, "property") output = upsertMetaTag(output, "og:title", metadata.title, "property") output = upsertMetaTag(output, "og:description", metadata.description, "property") output = upsertMetaTag(output, "og:image", metadata.image, "property") output = upsertMetaTag(output, "twitter:card", "summary_large_image", "name") output = upsertMetaTag(output, "twitter:site", metadata.site, "name") output = upsertMetaTag(output, "twitter:url", metadata.url, "name") output = upsertMetaTag(output, "twitter:title", metadata.title, "name") output = upsertMetaTag(output, "twitter:description", metadata.description, "name") output = upsertMetaTag(output, "twitter:image", metadata.image, "name") return output } const renderIndex = async requestUrl => { if (!isJoinInvitePath(requestUrl.pathname)) { return INDEX_TEMPLATE } const invite = parseInvite(requestUrl) if (!invite) { return INDEX_TEMPLATE } const relayMetadata = await fetchRelayMetadata(invite.relayUrl) const inviteMeta = buildInviteMeta(requestUrl, invite, relayMetadata) return injectInviteMeta(INDEX_TEMPLATE, inviteMeta) } const notFound = (response, message = "Not found") => { response.statusCode = 404 response.setHeader("Content-Type", "text/plain; charset=utf-8") response.end(message) } const sendHtml = (response, html, method, dynamic) => { response.statusCode = 200 response.setHeader("Content-Type", "text/html; charset=utf-8") response.setHeader("Cache-Control", dynamic ? "no-store" : "no-cache") if (method === "HEAD") { response.end() return } response.end(html) } const sendStaticFile = (response, filePath, size, method) => { const extension = path.extname(filePath).toLowerCase() const mimeType = MIME_TYPES[extension] || "application/octet-stream" const immutableAsset = filePath.includes(`${path.sep}_app${path.sep}immutable${path.sep}`) response.statusCode = 200 response.setHeader("Content-Type", mimeType) response.setHeader("Content-Length", String(size)) response.setHeader( "Cache-Control", immutableAsset ? "public, max-age=31536000, immutable" : "public, max-age=3600", ) if (method === "HEAD") { response.end() return } const stream = createReadStream(filePath) stream.on("error", () => { if (!response.headersSent) { response.statusCode = 500 response.end("Internal Server Error") return } response.destroy() }) stream.pipe(response) } const server = http.createServer(async (request, response) => { try { const method = request.method || "GET" if (!["GET", "HEAD"].includes(method)) { response.statusCode = 405 response.setHeader("Allow", "GET, HEAD") response.end("Method Not Allowed") return } const origin = requestOrigin(request) const requestUrl = new URL(request.url || "/", origin) const staticFile = await findStaticFile(requestUrl.pathname) if (staticFile) { sendStaticFile(response, staticFile.filePath, staticFile.size, method) return } if (path.extname(requestUrl.pathname)) { notFound(response) return } const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r") const html = await renderIndex(requestUrl) sendHtml(response, html, method, dynamicInvite) } catch { response.statusCode = 500 response.setHeader("Content-Type", "text/plain; charset=utf-8") response.end("Internal Server Error") } }) server.listen(PORT, HOST, () => { console.log(`Flotilla server running on http://${HOST}:${PORT}`) })