import path from "node:path" import {promises as fs} from "node:fs" import {fileURLToPath} from "node:url" import {serve} from "@hono/node-server" import {serveStatic} from "@hono/node-server/serve-static" import {fetchRelay} from "@welshman/app" import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util" import {load} from "cheerio" import {Hono} from "hono" 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 ALLOWED_METHODS = new Set(["GET", "HEAD"]) const DEFAULT_IMAGE_PATH = "/maskable-icon-512x512.png" const HTML_HEADERS = {"Content-Type": "text/html; charset=utf-8"} 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 hasMetadata = metadata => Object.keys(metadata).length > 0 const firstHeaderValue = value => { if (typeof value !== "string") { return undefined } return sanitizeText(value.split(",")[0], 256) } const requestUrlFromContext = context => { const requestUrl = new URL(context.req.url) const forwardedProto = firstHeaderValue(context.req.header("x-forwarded-proto")) const forwardedHost = firstHeaderValue(context.req.header("x-forwarded-host")) try { if (forwardedProto === "http" || forwardedProto === "https") { requestUrl.protocol = `${forwardedProto}:` } if (forwardedHost) { requestUrl.host = forwardedHost } } catch { return requestUrl } return requestUrl } const absoluteUrlFromRequest = (requestUrl, value) => { try { return new URL(value, requestUrl.origin).toString() } catch { return value } } 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 normalizeRelayInput = value => { const trimmed = value.trim() if (!trimmed) { return undefined } let relayInput = trimmed try { const parsed = new URL(trimmed) if (parsed.protocol === "http:") { parsed.protocol = "ws:" relayInput = parsed.toString() } if (parsed.protocol === "https:") { parsed.protocol = "wss:" relayInput = parsed.toString() } } catch { relayInput = trimmed } if (!isRelayUrl(relayInput)) { return undefined } try { const relayUrl = new URL(normalizeRelayUrl(relayInput)) relayUrl.hash = "" relayUrl.search = "" return relayUrl.toString() } catch { return undefined } } const relayToInfoUrl = relayUrl => { try { const relayHttpUrl = new URL(relayUrl) if (relayHttpUrl.protocol === "ws:") { relayHttpUrl.protocol = "http:" } else if (relayHttpUrl.protocol === "wss:") { relayHttpUrl.protocol = "https:" } else { return undefined } relayHttpUrl.hash = "" relayHttpUrl.search = "" return relayHttpUrl.toString() } catch { return undefined } } const withTimeout = async (promise, timeoutMs) => new Promise(resolve => { if (timeoutMs <= 0) { resolve(undefined) return } const timeout = setTimeout(() => resolve(undefined), timeoutMs) promise .then(resolve) .catch(() => resolve(undefined)) .finally(() => clearTimeout(timeout)) }) const findMeta = ($, key) => { const normalizedKey = key.toLowerCase() return $("meta") .filter((_, element) => { const meta = $(element) const name = meta.attr("name")?.toLowerCase() const property = meta.attr("property")?.toLowerCase() return name === normalizedKey || property === normalizedKey }) .first() } const readMetaContent = ($, key) => findMeta($, key).attr("content") const readIconHref = $ => { let href $("link[rel]").each((_, element) => { if (href) { return } const link = $(element) const rel = link.attr("rel")?.toLowerCase() || "" if (rel.includes("icon")) { href = link.attr("href") } }) return href } const findLinkRel = ($, rel) => $("link[rel]") .filter((_, element) => ($(element).attr("rel")?.toLowerCase() || "").includes(rel)) .first() const extractHtmlMetadata = (html, baseUrl) => { const $ = load(html) const name = sanitizeText($("title").first().text(), 80) const description = sanitizeText(readMetaContent($, "description") || "", 180) const icon = normalizeImageUrl( readMetaContent($, "og:image") || readMetaContent($, "twitter:image") || readIconHref($) || "", baseUrl, ) return { ...(name ? {name} : {}), ...(description ? {description} : {}), ...(icon ? {icon} : {}), } } const normalizeRelayMetadata = (relayMetadata, relayUrl) => { if (!isRecord(relayMetadata)) { return {} } const infoUrl = relayToInfoUrl(relayUrl) || relayUrl const name = sanitizeText(relayMetadata.name || relayMetadata.title, 80) const description = sanitizeText(relayMetadata.description, 180) const icon = normalizeImageUrl( relayMetadata.icon || relayMetadata.picture || relayMetadata.image, infoUrl, ) return { ...(name ? {name} : {}), ...(description ? {description} : {}), ...(icon ? {icon} : {}), } } const fetchHtmlRelayMetadata = async (relayUrl, timeoutMs) => { const infoUrl = relayToInfoUrl(relayUrl) if (!infoUrl || timeoutMs <= 0) { return {} } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const response = await fetch(infoUrl, { headers: { Accept: "text/html, application/xhtml+xml, */*;q=0.1", }, redirect: "follow", signal: controller.signal, }) if (!response.ok) { return {} } return extractHtmlMetadata(await response.text(), infoUrl) } catch { return {} } finally { clearTimeout(timeout) } } 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 deadline = Date.now() + REQUEST_TIMEOUT_MS const remainingTimeout = () => Math.max(0, deadline - Date.now()) const relay = await withTimeout(fetchRelay(relayUrl), remainingTimeout()) let metadata = normalizeRelayMetadata(relay, relayUrl) if (!hasMetadata(metadata)) { metadata = await fetchHtmlRelayMetadata(relayUrl, remainingTimeout()) } setCachedRelayData( relayUrl, metadata, hasMetadata(metadata) ? 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 INDEX_DOCUMENT = load(INDEX_TEMPLATE) const DEFAULT_PLATFORM_NAME = sanitizeText(process.env.VITE_PLATFORM_NAME, 80) || sanitizeText(readMetaContent(INDEX_DOCUMENT, "og:title"), 80) || sanitizeText(readMetaContent(INDEX_DOCUMENT, "twitter:title"), 80) || "Flotilla" const DEFAULT_PLATFORM_DESCRIPTION = sanitizeText(process.env.VITE_PLATFORM_DESCRIPTION, 180) || sanitizeText(readMetaContent(INDEX_DOCUMENT, "description"), 180) || "Flotilla is nostr - for communities." const buildInviteDescription = ({spaceName, relayDisplay, 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 (relayDisplay) { parts.push(`Relay: ${relayDisplay}.`) } if (relayDescription) { parts.push(relayDescription) } else { parts.push(DEFAULT_PLATFORM_DESCRIPTION) } return sanitizeText(parts.join(" "), 220) || DEFAULT_PLATFORM_DESCRIPTION } const buildInviteMeta = (requestUrl, invite, relayMetadata) => { const relayDisplay = displayRelayUrl(invite.relayUrl) 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, relayDisplay, relayDescription}) const image = relayMetadata.icon || absoluteUrlFromRequest(requestUrl, DEFAULT_IMAGE_PATH) const url = requestUrl.toString() const site = requestUrl.origin return {title, description, image, url, site} } const ensureHead = $ => { const existingHead = $("head").first() if (existingHead.length > 0) { return existingHead } $("html").prepend("
") return $("head").first() } const upsertTitle = ($, title) => { const head = ensureHead($) let titleTag = head.children("title").first() if (titleTag.length === 0) { titleTag = $("