Files
flotilla/server.js
T
2026-04-17 17:44:40 +00:00

427 lines
11 KiB
JavaScript

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(
`(<meta\\s+[^>]*${attribute}=["']${escapedKey}["'][^>]*content=["'])[^"']*(["'][^>]*>)`,
"i",
)
if (pattern.test(html)) {
return html.replace(pattern, `$1${escapedValue}$2`)
}
return html.replace(
"</head>",
` <meta ${attribute}="${escapeHtml(key)}" content="${escapedValue}" />\n </head>`,
)
}
function readMetaContent(html, key) {
const escapedKey = escapeRegExp(key)
const pattern = new RegExp(
`<meta\\s+[^>]*(?: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("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}