diff --git a/Dockerfile b/Dockerfile
index 780ce934..0a5b6c74 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,5 +28,8 @@ 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.js ./server.js
-CMD ["npx", "serve", "-s", "build"]
+EXPOSE 3000
+
+CMD ["node", "server.js"]
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/android/app/src/main/res/drawable-land-night-hdpi/splash.png b/android/app/src/main/res/drawable-land-night-hdpi/splash.png
index 6db47ae5..383a9e72 100644
Binary files a/android/app/src/main/res/drawable-land-night-hdpi/splash.png and b/android/app/src/main/res/drawable-land-night-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-land-night-ldpi/splash.png b/android/app/src/main/res/drawable-land-night-ldpi/splash.png
index 28d22ccf..079db82c 100644
Binary files a/android/app/src/main/res/drawable-land-night-ldpi/splash.png and b/android/app/src/main/res/drawable-land-night-ldpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-land-night-mdpi/splash.png b/android/app/src/main/res/drawable-land-night-mdpi/splash.png
index 52342d0f..26659f95 100644
Binary files a/android/app/src/main/res/drawable-land-night-mdpi/splash.png and b/android/app/src/main/res/drawable-land-night-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-land-night-xhdpi/splash.png b/android/app/src/main/res/drawable-land-night-xhdpi/splash.png
index 6117d3d5..05eef03d 100644
Binary files a/android/app/src/main/res/drawable-land-night-xhdpi/splash.png and b/android/app/src/main/res/drawable-land-night-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png
index fa1c60bf..17e95bff 100644
Binary files a/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png and b/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png
index e7ab5806..7bea79e9 100644
Binary files a/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png and b/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night/splash.png b/android/app/src/main/res/drawable-night/splash.png
index 28d22ccf..079db82c 100644
Binary files a/android/app/src/main/res/drawable-night/splash.png and b/android/app/src/main/res/drawable-night/splash.png differ
diff --git a/android/app/src/main/res/drawable-port-night-hdpi/splash.png b/android/app/src/main/res/drawable-port-night-hdpi/splash.png
index cbe4959b..7f8eff9d 100644
Binary files a/android/app/src/main/res/drawable-port-night-hdpi/splash.png and b/android/app/src/main/res/drawable-port-night-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-port-night-ldpi/splash.png b/android/app/src/main/res/drawable-port-night-ldpi/splash.png
index adf6c4df..11709e00 100644
Binary files a/android/app/src/main/res/drawable-port-night-ldpi/splash.png and b/android/app/src/main/res/drawable-port-night-ldpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-port-night-mdpi/splash.png b/android/app/src/main/res/drawable-port-night-mdpi/splash.png
index fcfb882f..5d289a73 100644
Binary files a/android/app/src/main/res/drawable-port-night-mdpi/splash.png and b/android/app/src/main/res/drawable-port-night-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-port-night-xhdpi/splash.png b/android/app/src/main/res/drawable-port-night-xhdpi/splash.png
index a21d53de..babb421d 100644
Binary files a/android/app/src/main/res/drawable-port-night-xhdpi/splash.png and b/android/app/src/main/res/drawable-port-night-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png
index d10c43f1..06ee692b 100644
Binary files a/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png and b/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png
index 9d720bfc..650acf0b 100644
Binary files a/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png and b/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png differ
diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany-dark.png b/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany-dark.png
index d44263cc..7b02fc9b 100644
Binary files a/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany-dark.png and b/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany-dark.png differ
diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany-dark.png b/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany-dark.png
index d44263cc..7b02fc9b 100644
Binary files a/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany-dark.png and b/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany-dark.png differ
diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany-dark.png b/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany-dark.png
index d44263cc..7b02fc9b 100644
Binary files a/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany-dark.png and b/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany-dark.png differ
diff --git a/package.json b/package.json
index 6bd45e94..73be3f18 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
+ "start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
diff --git a/server.js b/server.js
new file mode 100644
index 00000000..51cbfcc0
--- /dev/null
+++ b/server.js
@@ -0,0 +1,684 @@
+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}`)
+})
diff --git a/src/app.html b/src/app.html
index 91fcbd3c..05fab318 100644
--- a/src/app.html
+++ b/src/app.html
@@ -7,10 +7,10 @@
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
-
-
-
-
+
+
+
+