diff --git a/Dockerfile b/Dockerfile index 780ce934..013e7632 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,5 +28,6 @@ WORKDIR /app # Copy only the built output - no source, no .env, no dev deps COPY --from=builder /app/build ./build +COPY --from=builder /app/server.mjs ./server.mjs -CMD ["npx", "serve", "-s", "build"] +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md index 6092fd32..8eacb8d9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ To run your own Flotilla, it's as simple as: ```sh pnpm install pnpm run build -npx serve -s build +pnpm run start ``` Or, if you prefer to use a container: diff --git a/package.json b/package.json index b0dc3763..ae3489ad 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite dev", "build": "./build.sh", + "start": "node server.mjs", "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "tauri:dev": "tauri dev", "tauri:build": "tauri build", diff --git a/server.mjs b/server.mjs new file mode 100644 index 00000000..0612614f --- /dev/null +++ b/server.mjs @@ -0,0 +1,379 @@ +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 = `