fix(metadata): add case-insensitive HTML title fallback parsing for invite links #248
+6
-1
@@ -26,7 +26,12 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only the built output - no source, no .env, no dev deps
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -61,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",
|
||||
@@ -81,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",
|
||||
@@ -88,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",
|
||||
|
||||
Generated
+141
@@ -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
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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"
|
||||
|
||||
import {serve} from "@hono/node-server"
|
||||
import {serveStatic} from "@hono/node-server/serve-static"
|
||||
import {fetchRelay} from "@welshman/app"
|
||||
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||
import {load} from "cheerio"
|
||||
import {Hono} from "hono"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
@@ -30,35 +35,9 @@ const NEGATIVE_CACHE_TTL_MS = readPositiveInt(
|
||||
2 * 60 * 1000,
|
||||
)
|
||||
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
|
||||
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 ALLOWED_METHODS = new Set(["GET", "HEAD"])
|
||||
const DEFAULT_IMAGE_PATH = "/maskable-icon-512x512.png"
|
||||
const HTML_HEADERS = {"Content-Type": "text/html; charset=utf-8"}
|
||||
|
||||
const truncate = (value, limit) => {
|
||||
if (value.length <= limit) {
|
||||
@@ -88,98 +67,41 @@ const sanitizeText = (value, limit) => {
|
||||
|
||||
const isRecord = value => Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
const readMetaContent = (html, key) => {
|
||||
const pattern = new RegExp(
|
||||
`<meta\\s+[^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`,
|
||||
"i",
|
||||
)
|
||||
const match = html.match(pattern)
|
||||
const hasMetadata = metadata => Object.keys(metadata).length > 0
|
||||
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
const upsertTitle = (html, title) => {
|
||||
const escapedTitle = escapeHtml(title)
|
||||
const pattern = /<title[^>]*>.*?<\/title>/is
|
||||
|
||||
if (pattern.test(html)) {
|
||||
return html.replace(pattern, `<title>${escapedTitle}</title>`)
|
||||
}
|
||||
|
||||
return html.replace("</head>", ` <title>${escapedTitle}</title>\n </head>`)
|
||||
}
|
||||
|
||||
const upsertMetaTag = (html, key, content, attribute) => {
|
||||
const pattern = new RegExp(
|
||||
`<meta\\s+[^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`,
|
||||
"i",
|
||||
)
|
||||
const tag = `<meta ${attribute}="${key}" content="${escapeHtml(content)}" />`
|
||||
|
||||
if (pattern.test(html)) {
|
||||
return html.replace(pattern, tag)
|
||||
}
|
||||
|
||||
return html.replace("</head>", ` ${tag}\n </head>`)
|
||||
}
|
||||
|
||||
const upsertCanonical = (html, href) => {
|
||||
const pattern = /<link\s+[^>]*rel=["']canonical["'][^>]*>/i
|
||||
const tag = `<link rel="canonical" href="${escapeHtml(href)}" />`
|
||||
|
||||
if (pattern.test(html)) {
|
||||
return html.replace(pattern, tag)
|
||||
}
|
||||
|
||||
return html.replace("</head>", ` ${tag}\n </head>`)
|
||||
}
|
||||
|
||||
const normalizeRelayInput = value => {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
const firstHeaderValue = value => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `wss://${trimmed}`
|
||||
return sanitizeText(value.split(",")[0], 256)
|
||||
}
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This routing logic is still incomplete and is pretty brittle. We should do something like this: This way it's clear which function is responsible for which route. Common utilities can be factored out (e.g. relay fetching, relay title generation, etc). This routing logic is still incomplete and is pretty brittle. We should do something like this:
```typescript
const routes = , [
[/^\/join\/?$/, getMetadataForInvite],
[/^\/spaces\/(RELAY_REGEX)\/?$/, getMetadataForSpace],
[/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/?$/, getMetadataForRoom],
[/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/?$/, getMetadataForCalendar],
[/^\/spaces\/(RELAY_REGEX)\/(ROOM_REGEX)\/calendar\/(ADDRESS_REGEX)\/?$/, getMetadataForCalendarEvent],
]
const getMetadataForRoute = (url: URL) => {
for (const [regex, getMetadata] of routes) {
const match = url.pathname.match(regex)
if (match) {
return getMetadata(url, match)
}
}
}
const meta = getMetadataForRoute(requestUrl)
```
This way it's clear which function is responsible for which route. Common utilities can be factored out (e.g. relay fetching, relay title generation, etc).
|
||||
|
||||
const requestUrlFromContext = context => {
|
||||
const requestUrl = new URL(context.req.url)
|
||||
const forwardedProto = firstHeaderValue(context.req.header("x-forwarded-proto"))
|
||||
const forwardedHost = firstHeaderValue(context.req.header("x-forwarded-host"))
|
||||
|
||||
try {
|
||||
const relayUrl = new URL(withScheme)
|
||||
|
||||
if (!["ws:", "wss:", "http:", "https:"].includes(relayUrl.protocol)) {
|
||||
return undefined
|
||||
if (forwardedProto === "http" || forwardedProto === "https") {
|
||||
requestUrl.protocol = `${forwardedProto}:`
|
||||
}
|
||||
|
||||
relayUrl.hash = ""
|
||||
relayUrl.search = ""
|
||||
|
||||
if (relayUrl.pathname !== "/") {
|
||||
relayUrl.pathname = relayUrl.pathname.replace(/\/+$/, "")
|
||||
if (forwardedHost) {
|
||||
requestUrl.host = forwardedHost
|
||||
}
|
||||
|
||||
return relayUrl.toString()
|
||||
} catch {
|
||||
return undefined
|
||||
return requestUrl
|
||||
}
|
||||
|
||||
return requestUrl
|
||||
}
|
||||
|
||||
const relayToInfoUrl = relayUrl => {
|
||||
const absoluteUrlFromRequest = (requestUrl, value) => {
|
||||
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()
|
||||
return new URL(value, requestUrl.origin).toString()
|
||||
} catch {
|
||||
return undefined
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,128 +129,188 @@ const normalizeImageUrl = (value, baseUrl) => {
|
||||
}
|
||||
}
|
||||
|
||||
const decodeHtmlEntities = value =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
const normalizeRelayInput = value => {
|
||||
const trimmed = value.trim()
|
||||
|
||||
const readHtmlAttribute = (tag, key) => {
|
||||
const pattern = new RegExp(`${escapeRegExp(key)}=["']([^"']*)["']`, "i")
|
||||
const match = tag.match(pattern)
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return match?.[1]
|
||||
}
|
||||
let relayInput = trimmed
|
||||
|
||||
const readHtmlTagContent = (html, tag) => {
|
||||
const pattern = new RegExp(`<${escapeRegExp(tag)}[^>]*>([\\s\\S]*?)<\\/${escapeRegExp(tag)}>`, "i")
|
||||
const match = html.match(pattern)
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
const readHtmlMetaContent = (html, key) => {
|
||||
const forwardPattern = new RegExp(
|
||||
`<meta\\s+[^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*content=["']([^"']*)["'][^>]*>`,
|
||||
"i",
|
||||
)
|
||||
const reversePattern = new RegExp(
|
||||
`<meta\\s+[^>]*content=["']([^"']*)["'][^>]*(?:name|property)=["']${escapeRegExp(key)}["'][^>]*>`,
|
||||
"i",
|
||||
)
|
||||
|
||||
return html.match(forwardPattern)?.[1] || html.match(reversePattern)?.[1]
|
||||
}
|
||||
|
||||
const readHtmlIconHref = html => {
|
||||
const links = html.match(/<link\s+[^>]*>/gi) || []
|
||||
|
||||
for (const link of links) {
|
||||
const rel = readHtmlAttribute(link, "rel")?.toLowerCase() || ""
|
||||
|
||||
if (!rel.includes("icon")) {
|
||||
continue
|
||||
if (parsed.protocol === "http:") {
|
||||
parsed.protocol = "ws:"
|
||||
relayInput = parsed.toString()
|
||||
}
|
||||
|
||||
const href = readHtmlAttribute(link, "href")
|
||||
if (parsed.protocol === "https:") {
|
||||
parsed.protocol = "wss:"
|
||||
relayInput = parsed.toString()
|
||||
}
|
||||
} catch {
|
||||
relayInput = trimmed
|
||||
}
|
||||
|
||||
if (!isRelayUrl(relayInput)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const relayUrl = new URL(normalizeRelayUrl(relayInput))
|
||||
|
||||
relayUrl.hash = ""
|
||||
relayUrl.search = ""
|
||||
|
||||
return relayUrl.toString()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const relayToInfoUrl = relayUrl => {
|
||||
try {
|
||||
const relayHttpUrl = new URL(relayUrl)
|
||||
|
||||
if (relayHttpUrl.protocol === "ws:") {
|
||||
relayHttpUrl.protocol = "http:"
|
||||
} else if (relayHttpUrl.protocol === "wss:") {
|
||||
relayHttpUrl.protocol = "https:"
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
relayHttpUrl.hash = ""
|
||||
relayHttpUrl.search = ""
|
||||
|
||||
return relayHttpUrl.toString()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const withTimeout = async (promise, timeoutMs) =>
|
||||
new Promise(resolve => {
|
||||
if (timeoutMs <= 0) {
|
||||
resolve(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => resolve(undefined), timeoutMs)
|
||||
|
||||
promise
|
||||
.then(resolve)
|
||||
.catch(() => resolve(undefined))
|
||||
.finally(() => clearTimeout(timeout))
|
||||
})
|
||||
|
||||
const findMeta = ($, key) => {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
|
||||
return $("meta")
|
||||
.filter((_, element) => {
|
||||
const meta = $(element)
|
||||
const name = meta.attr("name")?.toLowerCase()
|
||||
const property = meta.attr("property")?.toLowerCase()
|
||||
|
||||
return name === normalizedKey || property === normalizedKey
|
||||
})
|
||||
.first()
|
||||
}
|
||||
|
||||
const readMetaContent = ($, key) => findMeta($, key).attr("content")
|
||||
|
||||
const readIconHref = $ => {
|
||||
let href
|
||||
|
||||
$("link[rel]").each((_, element) => {
|
||||
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}
|
||||
return
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const nestedIndex = path.join(candidatePath, "index.html")
|
||||
const indexStats = await fs.stat(nestedIndex)
|
||||
const link = $(element)
|
||||
const rel = link.attr("rel")?.toLowerCase() || ""
|
||||
|
||||
if (indexStats.isFile()) {
|
||||
return {filePath: nestedIndex, size: indexStats.size}
|
||||
}
|
||||
if (rel.includes("icon")) {
|
||||
href = link.attr("href")
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
})
|
||||
|
||||
return href
|
||||
}
|
||||
|
||||
const findLinkRel = ($, rel) =>
|
||||
$("link[rel]")
|
||||
.filter((_, element) => ($(element).attr("rel")?.toLowerCase() || "").includes(rel))
|
||||
.first()
|
||||
|
||||
const extractHtmlMetadata = (html, baseUrl) => {
|
||||
const $ = load(html)
|
||||
const name = sanitizeText($("title").first().text(), 80)
|
||||
const description = sanitizeText(readMetaContent($, "description") || "", 180)
|
||||
const icon = normalizeImageUrl(
|
||||
readMetaContent($, "og:image") || readMetaContent($, "twitter:image") || readIconHref($) || "",
|
||||
baseUrl,
|
||||
)
|
||||
|
||||
return {
|
||||
...(name ? {name} : {}),
|
||||
...(description ? {description} : {}),
|
||||
...(icon ? {icon} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeRelayMetadata = (relayMetadata, relayUrl) => {
|
||||
if (!isRecord(relayMetadata)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return undefined
|
||||
const infoUrl = relayToInfoUrl(relayUrl) || relayUrl
|
||||
const name = sanitizeText(relayMetadata.name || relayMetadata.title, 80)
|
||||
const description = sanitizeText(relayMetadata.description, 180)
|
||||
const icon = normalizeImageUrl(
|
||||
relayMetadata.icon || relayMetadata.picture || relayMetadata.image,
|
||||
infoUrl,
|
||||
)
|
||||
|
||||
return {
|
||||
...(name ? {name} : {}),
|
||||
...(description ? {description} : {}),
|
||||
...(icon ? {icon} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const fetchHtmlRelayMetadata = async (relayUrl, timeoutMs) => {
|
||||
const infoUrl = relayToInfoUrl(relayUrl)
|
||||
|
||||
if (!infoUrl || timeoutMs <= 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(infoUrl, {
|
||||
headers: {
|
||||
Accept: "text/html, application/xhtml+xml, */*;q=0.1",
|
||||
},
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return extractHtmlMetadata(await response.text(), infoUrl)
|
||||
} catch {
|
||||
return {}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
const cacheByRelay = new Map()
|
||||
@@ -381,84 +363,19 @@ const fetchRelayMetadata = async relayUrl => {
|
||||
}
|
||||
|
||||
const loader = (async () => {
|
||||
const infoUrl = relayToInfoUrl(relayUrl)
|
||||
const deadline = Date.now() + REQUEST_TIMEOUT_MS
|
||||
const remainingTimeout = () => Math.max(0, deadline - Date.now())
|
||||
const relay = await withTimeout(fetchRelay(relayUrl), remainingTimeout())
|
||||
let metadata = normalizeRelayMetadata(relay, 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)
|
||||
if (!hasMetadata(metadata)) {
|
||||
metadata = await fetchHtmlRelayMetadata(relayUrl, remainingTimeout())
|
||||
}
|
||||
|
||||
setCachedRelayData(
|
||||
relayUrl,
|
||||
metadata,
|
||||
Object.keys(metadata).length > 0 ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS,
|
||||
hasMetadata(metadata) ? POSITIVE_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS,
|
||||
)
|
||||
|
||||
return metadata
|
||||
@@ -498,17 +415,18 @@ const loadIndexTemplate = async () => {
|
||||
}
|
||||
|
||||
const INDEX_TEMPLATE = await loadIndexTemplate()
|
||||
const INDEX_DOCUMENT = load(INDEX_TEMPLATE)
|
||||
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) ||
|
||||
sanitizeText(readMetaContent(INDEX_DOCUMENT, "og:title"), 80) ||
|
||||
sanitizeText(readMetaContent(INDEX_DOCUMENT, "twitter:title"), 80) ||
|
||||
"Flotilla"
|
||||
const DEFAULT_PLATFORM_DESCRIPTION =
|
||||
sanitizeText(process.env.VITE_PLATFORM_DESCRIPTION, 180) ||
|
||||
sanitizeText(readMetaContent(INDEX_TEMPLATE, "description"), 180) ||
|
||||
sanitizeText(readMetaContent(INDEX_DOCUMENT, "description"), 180) ||
|
||||
"Flotilla is nostr - for communities."
|
||||
|
||||
const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
|
||||
const buildInviteDescription = ({spaceName, relayDisplay, relayDescription}) => {
|
||||
const parts = []
|
||||
|
||||
if (spaceName) {
|
||||
@@ -517,8 +435,8 @@ const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
|
||||
parts.push(`You are invited to join a space on ${DEFAULT_PLATFORM_NAME}.`)
|
||||
}
|
||||
|
||||
if (relayHost) {
|
||||
parts.push(`Relay: ${relayHost}.`)
|
||||
if (relayDisplay) {
|
||||
parts.push(`Relay: ${relayDisplay}.`)
|
||||
}
|
||||
|
||||
if (relayDescription) {
|
||||
@@ -531,47 +449,90 @@ const buildInviteDescription = ({spaceName, relayHost, relayDescription}) => {
|
||||
}
|
||||
|
||||
const buildInviteMeta = (requestUrl, invite, relayMetadata) => {
|
||||
let relayHost = ""
|
||||
|
||||
try {
|
||||
relayHost = new URL(invite.relayUrl).host
|
||||
} catch {
|
||||
relayHost = ""
|
||||
}
|
||||
|
||||
const relayDisplay = displayRelayUrl(invite.relayUrl)
|
||||
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 description = buildInviteDescription({spaceName, relayDisplay, relayDescription})
|
||||
const image = relayMetadata.icon || absoluteUrlFromRequest(requestUrl, DEFAULT_IMAGE_PATH)
|
||||
const url = requestUrl.toString()
|
||||
const site = `${requestUrl.protocol}//${requestUrl.host}`
|
||||
const site = requestUrl.origin
|
||||
|
||||
return {title, description, image, url, site}
|
||||
}
|
||||
|
||||
const ensureHead = $ => {
|
||||
const existingHead = $("head").first()
|
||||
|
||||
if (existingHead.length > 0) {
|
||||
return existingHead
|
||||
}
|
||||
|
Khushvendra marked this conversation as resolved
Outdated
hodlbod
commented
This PR is full of just in case things like this, why do we need so much verbose stuff when we have full control over the html template? Just assume it's there, because it is. This would cut the PR in half at least. This PR is full of just in case things like this, why do we need so much verbose stuff when we have full control over the html template? Just assume it's there, because it is. This would cut the PR in half at least.
|
||||
|
||||
$("html").prepend("<head></head>")
|
||||
|
||||
return $("head").first()
|
||||
}
|
||||
|
||||
const upsertTitle = ($, title) => {
|
||||
const head = ensureHead($)
|
||||
let titleTag = head.children("title").first()
|
||||
|
||||
if (titleTag.length === 0) {
|
||||
titleTag = $("<title></title>")
|
||||
head.prepend(titleTag)
|
||||
}
|
||||
|
||||
titleTag.text(title)
|
||||
}
|
||||
|
||||
const upsertMetaTag = ($, key, content, attribute) => {
|
||||
const head = ensureHead($)
|
||||
let tag = findMeta($, key)
|
||||
|
||||
if (tag.length === 0) {
|
||||
tag = $("<meta>")
|
||||
head.append(tag)
|
||||
}
|
||||
|
||||
tag.removeAttr(attribute === "name" ? "property" : "name")
|
||||
tag.attr(attribute, key)
|
||||
tag.attr("content", content)
|
||||
}
|
||||
|
||||
const upsertCanonical = ($, href) => {
|
||||
const head = ensureHead($)
|
||||
let tag = findLinkRel($, "canonical")
|
||||
|
||||
if (tag.length === 0) {
|
||||
tag = $("<link>")
|
||||
head.append(tag)
|
||||
}
|
||||
|
||||
tag.attr("rel", "canonical")
|
||||
tag.attr("href", href)
|
||||
}
|
||||
|
||||
const injectInviteMeta = (html, metadata) => {
|
||||
let output = html
|
||||
const $ = load(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")
|
||||
upsertTitle($, metadata.title)
|
||||
upsertCanonical($, metadata.url)
|
||||
upsertMetaTag($, "description", metadata.description, "name")
|
||||
upsertMetaTag($, "og:type", "website", "property")
|
||||
upsertMetaTag($, "og:url", metadata.url, "property")
|
||||
upsertMetaTag($, "og:title", metadata.title, "property")
|
||||
upsertMetaTag($, "og:description", metadata.description, "property")
|
||||
upsertMetaTag($, "og:image", metadata.image, "property")
|
||||
upsertMetaTag($, "twitter:card", "summary_large_image", "name")
|
||||
upsertMetaTag($, "twitter:site", metadata.site, "name")
|
||||
upsertMetaTag($, "twitter:url", metadata.url, "name")
|
||||
upsertMetaTag($, "twitter:title", metadata.title, "name")
|
||||
upsertMetaTag($, "twitter:description", metadata.description, "name")
|
||||
upsertMetaTag($, "twitter:image", metadata.image, "name")
|
||||
|
||||
return output
|
||||
return $.html()
|
||||
}
|
||||
|
||||
const renderIndex = async requestUrl => {
|
||||
@@ -591,94 +552,64 @@ const renderIndex = async requestUrl => {
|
||||
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 isImmutableAsset = filePath => filePath.split(path.sep).join("/").includes("/_app/immutable/")
|
||||
|
||||
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")
|
||||
const getStaticCacheControl = filePath =>
|
||||
path.basename(filePath) === "index.html"
|
||||
? "no-cache"
|
||||
: isImmutableAsset(filePath)
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "public, max-age=3600"
|
||||
|
||||
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()
|
||||
const respondHtml = (html, isHeadRequest, cacheControl) =>
|
||||
new Response(isHeadRequest ? undefined : html, {
|
||||
headers: {
|
||||
...HTML_HEADERS,
|
||||
"Cache-Control": cacheControl,
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
|
||||
stream.pipe(response)
|
||||
}
|
||||
const app = new Hono()
|
||||
|
||||
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")
|
||||
app.use("*", async (context, next) => {
|
||||
if (!ALLOWED_METHODS.has(context.req.method)) {
|
||||
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
|
||||
}
|
||||
|
||||
await next()
|
||||
})
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
|
||||
app.use(
|
||||
"*",
|
||||
serveStatic({
|
||||
root: BUILD_DIR,
|
||||
onFound: (filePath, context) => {
|
||||
context.header("Cache-Control", getStaticCacheControl(filePath))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
app.on(["GET", "HEAD"], "*", async context => {
|
||||
const requestUrl = requestUrlFromContext(context)
|
||||
|
||||
if (path.extname(requestUrl.pathname)) {
|
||||
return context.text("Not found", 404)
|
||||
}
|
||||
|
||||
const dynamicInvite = isJoinInvitePath(requestUrl.pathname) && requestUrl.searchParams.has("r")
|
||||
const html = await renderIndex(requestUrl)
|
||||
|
||||
return respondHtml(html, context.req.method === "HEAD", dynamicInvite ? "no-store" : "no-cache")
|
||||
})
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
hostname: HOST,
|
||||
port: PORT,
|
||||
},
|
||||
() => {
|
||||
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user
These will always be defined (because .env is checked in to version control), no need for a fallback