diff --git a/Dockerfile b/Dockerfile index 780ce934..ed71b94f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,15 @@ FROM node:20-alpine WORKDIR /app -# Copy only the built output - no source, no .env, no dev deps -COPY --from=builder /app/build ./build +# Install production dependencies needed by the Node server runtime +RUN npm install -g pnpm@10.33.0 +COPY package.json pnpm-lock.yaml ./ +RUN pnpm i --prod --frozen-lockfile --ignore-scripts -CMD ["npx", "serve", "-s", "build"] +# Copy only the built output and server source - no app source, no .env, no dev deps +COPY --from=builder /app/build ./build +COPY --from=builder /app/server.js ./server.js + +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..6539e539 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", @@ -60,6 +61,7 @@ "@capawesome/capacitor-badge": "^8.0.0", "@getalby/lightning-tools": "^6.1.0", "@getalby/sdk": "^5.1.2", + "@hono/node-server": "^2.0.0", "@noble/curves": "^1.9.7", "@pomade/core": "^0.2.3", "@poppanator/sveltekit-svg": "^4.2.1", @@ -80,6 +82,7 @@ "@welshman/signer": "^0.8.13", "@welshman/store": "^0.8.13", "@welshman/util": "^0.8.13", + "cheerio": "^1.2.0", "compressorjs-next": "^1.1.2", "daisyui": "^5.5.19", "date-picker-svelte": "^2.17.0", @@ -87,6 +90,7 @@ "emoji-picker-element": "^1.28.1", "emoji-picker-element-data": "^1.8.0", "fuse.js": "^7.1.0", + "hono": "^4.12.15", "husky": "^9.1.7", "idb": "^8.0.3", "livekit-client": "^2.17.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e98a9f53..c28ac9e0 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/server.js b/server.js new file mode 100644 index 00000000..4b9df7a2 --- /dev/null +++ b/server.js @@ -0,0 +1,280 @@ +import path from "node:path" +import {promises as fs} from "node:fs" +import {fileURLToPath} from "node:url" + +import "dotenv/config" +import {serve} from "@hono/node-server" +import {serveStatic} from "@hono/node-server/serve-static" +import {loadRelay} from "@welshman/app" +import {displayRelayUrl, 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 PORT = parseInt(process.env.PORT || "", 10) || 3000 +const HOST = process.env.HOST || "0.0.0.0" + +let TEMPLATE_HTML = "" +try { + TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8") +} catch (error) { + console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`) + process.exit(1) +} + +const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME +const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION + +// Match client-side decode logic +const decodeRelay = url => { + try { + return normalizeRelayUrl(decodeURIComponent(url)) + } catch { + return undefined + } +} + +const requestUrlFromContext = context => { + const requestUrl = new URL(context.req.url) + const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim() + const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim() + + if (forwardedProto === "http" || forwardedProto === "https") { + requestUrl.protocol = `${forwardedProto}:` + } + + if (forwardedHost) { + requestUrl.host = forwardedHost + } + + return requestUrl +} + +const fetchRelayMeta = async relayUrl => { + if (!relayUrl) return undefined + try { + return await loadRelay(normalizeRelayUrl(relayUrl)) + } catch (err) { + console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err) + return undefined + } +} + +const buildDefaultImage = requestUrl => { + return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString() +} + +const getMetadataForInvite = async (url, match) => { + const relayParam = url.searchParams.get("r") + if (!relayParam) return undefined + + const relayMetadata = await fetchRelayMeta(relayParam) + if (!relayMetadata) return undefined + + const relayDisplay = displayRelayUrl(relayParam) + const spaceName = relayMetadata.name + const relayDescription = relayMetadata.description + + const title = spaceName + ? `Invite to ${spaceName} on ${PLATFORM_NAME}` + : `Invite to a Space on ${PLATFORM_NAME}` + + const parts = [] + if (spaceName) { + parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`) + } else { + parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`) + } + + if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`) + if (relayDescription) parts.push(relayDescription) + else parts.push(PLATFORM_DESCRIPTION) + + const description = parts.join(" ") + const image = + relayMetadata.icon || + relayMetadata.picture || + relayMetadata.image || + buildDefaultImage(url) + + return { + title, + description, + image, + url: url.toString(), + site: url.origin, + } +} + +const getMetadataForSpace = async (url, match) => { + const relayParam = decodeRelay(match[1]) + if (!relayParam) return undefined + + const relayMetadata = await fetchRelayMeta(relayParam) + if (!relayMetadata) return undefined + + const spaceName = relayMetadata.name || displayRelayUrl(relayParam) + + return { + title: `${spaceName} on ${PLATFORM_NAME}`, + description: relayMetadata.description || PLATFORM_DESCRIPTION, + image: + relayMetadata.icon || + relayMetadata.picture || + relayMetadata.image || + buildDefaultImage(url), + url: url.toString(), + site: url.origin, + } +} + +const getMetadataForSpaceSection = async (url, match) => { + const spaceMeta = await getMetadataForSpace(url, match) + if (!spaceMeta) return undefined + + const section = match[2] + const sectionName = section.charAt(0).toUpperCase() + section.slice(1) + spaceMeta.title = `${sectionName} on ${spaceMeta.title}` + return spaceMeta +} + +const getMetadataForSpaceItem = async (url, match) => { + const spaceMeta = await getMetadataForSpace(url, match) + if (!spaceMeta) return undefined + + const section = match[2] + let itemType = "Item" + if (section === "calendar") itemType = "Event" + if (section === "threads") itemType = "Thread" + if (section === "polls") itemType = "Poll" + if (section === "goals") itemType = "Goal" + if (section === "classifieds") itemType = "Listing" + + spaceMeta.title = `${itemType} on ${spaceMeta.title}` + return spaceMeta +} + +const getMetadataForRoom = async (url, match) => { + const spaceMeta = await getMetadataForSpace(url, match) + if (!spaceMeta) return undefined + + // Room metadata requires fetching from Nostr, which can be added later. + spaceMeta.title = `Room on ${spaceMeta.title}` + return spaceMeta +} + +const routes = [ + [/^\/join\/?$/, getMetadataForInvite], + [/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection], + [/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem], + [/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom], + [/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace], +] + +const getMetadataForRoute = async url => { + for (const [regex, getMetadata] of routes) { + const match = url.pathname.match(regex) + if (match) { + try { + return await getMetadata(url, match) + } catch (err) { + console.error(`Error generating metadata for route ${url.pathname}:`, err) + return undefined + } + } + } + return undefined +} + +const injectMeta = metadata => { + const $ = load(TEMPLATE_HTML) + + if (metadata.title) { + $("title").text(metadata.title) + $('meta[property="og:title"]').attr("content", metadata.title) + $('meta[name="twitter:title"]').attr("content", metadata.title) + } + + if (metadata.description) { + $('meta[name="description"]').attr("content", metadata.description) + $('meta[property="og:description"]').attr("content", metadata.description) + $('meta[name="twitter:description"]').attr("content", metadata.description) + } + + if (metadata.image) { + $('meta[property="og:image"]').attr("content", metadata.image) + $('meta[name="twitter:image"]').attr("content", metadata.image) + } + + if (metadata.url) { + $('meta[property="og:url"]').attr("content", metadata.url) + $('meta[name="twitter:site"]').attr("content", metadata.site) + $('meta[name="twitter:url"]').attr("content", metadata.url) + $('link[rel="canonical"]').attr("href", metadata.url) + } + + return $.html() +} + +const app = new Hono() + +// Only allow GET and HEAD requests +app.use("*", async (context, next) => { + const method = context.req.method + if (method !== "GET" && method !== "HEAD") { + return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"}) + } + await next() +}) + +// Serve static assets with appropriate caching +app.use( + "*", + serveStatic({ + root: BUILD_DIR, + onFound: (filePath, context) => { + const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/") + const cacheControl = + path.basename(filePath) === "index.html" + ? "no-cache" + : isImmutable + ? "public, max-age=31536000, immutable" + : "public, max-age=3600" + + context.header("Cache-Control", cacheControl) + }, + }), +) + +// SPA fallback for routes that don't match static files +app.get("*", async context => { + const requestUrl = requestUrlFromContext(context) + + // If the path has an extension, it's likely a missing static asset, not an SPA route + if (path.extname(requestUrl.pathname)) { + return context.text("Not found", 404) + } + + const metadata = await getMetadataForRoute(requestUrl) + const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML + + return context.html(html, 200, { + "Cache-Control": metadata ? "no-store" : "no-cache", + }) +}) + +serve( + { + fetch: app.fetch, + hostname: HOST, + port: PORT, + }, + () => { + console.log(`Flotilla server running on http://${HOST}:${PORT}`) + }, +) diff --git a/src/app.html b/src/app.html index 91fcbd3c..3bab4e05 100644 --- a/src/app.html +++ b/src/app.html @@ -2,15 +2,18 @@
+