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 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@getalby/sdk': specifier: ^5.1.2 version: 5.1.2(typescript@5.9.3) + '@hono/node-server': + specifier: ^2.0.0 + version: 2.0.0(hono@4.12.15) '@noble/curves': specifier: ^1.9.7 version: 1.9.7 @@ -122,6 +125,9 @@ importers: '@welshman/util': specifier: ^0.8.13 version: 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3)) + cheerio: + specifier: ^1.2.0 + version: 1.2.0 compressorjs-next: specifier: ^1.1.2 version: 1.1.2 @@ -143,6 +149,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 + hono: + specifier: ^4.12.15 + version: 4.12.15 husky: specifier: ^9.1.7 version: 9.1.7 @@ -1099,6 +1108,12 @@ packages: resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==} engines: {node: '>=14'} + '@hono/node-server@2.0.0': + resolution: {integrity: sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2468,6 +2483,13 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + chevrotain@7.1.1: resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==} @@ -2836,6 +2858,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -2847,6 +2872,14 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3240,6 +3273,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hono@4.12.15: + resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -3247,6 +3284,9 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -3255,6 +3295,10 @@ packages: ico-endec@0.1.6: resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -4021,6 +4065,15 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -4462,6 +4515,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.1.4: resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==} @@ -4897,6 +4953,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -5015,6 +5075,15 @@ packages: resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==} engines: {node: '>=6.0.0', npm: '>=3.10.0'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6234,6 +6303,10 @@ snapshots: transitivePeerDependencies: - typescript + '@hono/node-server@2.0.0(hono@4.12.15)': + dependencies: + hono: 4.12.15 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7648,6 +7721,29 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.25.0 + whatwg-mimetype: 4.0.0 + chevrotain@7.1.1: dependencies: regexp-to-ast: 0.5.0 @@ -8040,6 +8136,11 @@ snapshots: emoji-regex@8.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -8049,6 +8150,10 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + + entities@7.0.1: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -8558,16 +8663,29 @@ snapshots: he@1.2.0: {} + hono@4.12.15: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + husky@9.1.7: {} ico-endec@0.1.6: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + idb@7.1.1: {} idb@8.0.3: {} @@ -9274,6 +9392,19 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -9712,6 +9843,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + sax@1.1.4: {} sax@1.4.4: {} @@ -10237,6 +10370,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.25.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -10317,6 +10452,12 @@ snapshots: dependencies: sdp: 3.2.1 + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 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 @@
+