forked from coracle/flotilla
Update server.js
This commit is contained in:
@@ -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(
|
||||
`(<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("&", "&")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
}
|
||||
-379
@@ -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 = `<meta ${preferredKey}="${htmlEscape(keyValue)}" content="${htmlEscape(content)}" />`
|
||||
const pattern = new RegExp(`<meta[^>]*(?:name|property)=["']${escapedValue}["'][^>]*>`, "i")
|
||||
|
||||
if (pattern.test(html)) {
|
||||
return html.replace(pattern, tag)
|
||||
}
|
||||
|
||||
return html.replace("</head>", ` ${tag}\n</head>`)
|
||||
}
|
||||
|
||||
const replaceOrInsertTitle = (html, title) => {
|
||||
const tag = `<title>${htmlEscape(title)}</title>`
|
||||
|
||||
if (/<title[^>]*>.*<\/title>/i.test(html)) {
|
||||
return html.replace(/<title[^>]*>.*<\/title>/i, tag)
|
||||
}
|
||||
|
||||
return html.replace("</head>", ` ${tag}\n</head>`)
|
||||
}
|
||||
|
||||
const replaceOrInsertCanonical = (html, href) => {
|
||||
const tag = `<link rel="canonical" href="${htmlEscape(href)}" />`
|
||||
const pattern = /<link[^>]*rel=["']canonical["'][^>]*>/i
|
||||
|
||||
if (pattern.test(html)) {
|
||||
return html.replace(pattern, tag)
|
||||
}
|
||||
|
||||
return html.replace("</head>", ` ${tag}\n</head>`)
|
||||
}
|
||||
|
||||
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 [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
"<head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
"</head>",
|
||||
"<body>",
|
||||
" <div id=\"app\"></div>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].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}`)
|
||||
})
|
||||
Reference in New Issue
Block a user