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 3a433de1..7f20624a 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" /> - - - - + + + +