From 620b83920dbeb2af7cced55718351798491e029f Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 12 Aug 2026 18:56:33 +0000 Subject: [PATCH 1/4] self-hosted: static SEO files, canonical policy and responsive media Per-tenant robots.txt (with its Sitemap line), sitemap.xml and rss.xml are written by the existing sync pass into the served configs volume, built from one bounded bridge page (latest 100 posts), regenerated on a thirty-minute freshness window (files touched after unchanged writes so writeIfChanged cannot defeat the mtime check) and cleaned up by the same delete path and reconcile sweep as every other served file. nginx serves them per tenant in both server blocks with the generic robots as fallback. Managed instances now link their OWN feed (crawler-visible from the SSI snippet, and getRssFeedUrl follows for the visible link) instead of pointing at ecency.com. Canonical policy: a tenant with a verified custom domain canonicalizes to itself, a subdomain tenant to the ecency.com SSR page (home and per-post alike); og:url stays the served URL. Media: both card layouts and the Journal thumbnail emit srcset and sizes from render-helper's builders, so a phone stops downloading the desktop cut, and the per-post og:image goes through render-helper's own cover extraction and modern proxy path instead of a local regex duplicate and the legacy redirecting sized route. render-helper loads through its CJS build in the API: the package's node ESM entry crashes on a directory import, tracked in #1461. escapeHtml and the excerpt move to their own utils, since config-service, post-meta and seo-files now consume each other. --- .../self-hosted/hosting/api/package-lock.json | 267 ++++++++++++++++++ apps/self-hosted/hosting/api/package.json | 7 +- .../src/services/config-publish-lock.test.ts | 5 +- .../api/src/services/config-service.ts | 90 +++++- .../src/services/hivesigner-registry.test.ts | 1 + .../api/src/services/post-meta.test.ts | 6 +- .../hosting/api/src/services/post-meta.ts | 69 ++--- .../api/src/services/seo-files.test.ts | 142 ++++++++++ .../hosting/api/src/services/seo-files.ts | 213 ++++++++++++++ .../hosting/api/src/utils/escape-html.ts | 9 + .../hosting/api/src/utils/excerpt.ts | 15 + .../hosting/nginx-multi-tenant.conf | 24 ++ .../blog/components/blog-post-item.tsx | 14 + .../features/blog/layout/blog-navigation.tsx | 6 +- apps/self-hosted/src/index.tsx | 7 +- .../src/themes/journal/journal-post-card.tsx | 3 + .../src/utils/rss-feed-url.test.ts | 16 ++ apps/self-hosted/src/utils/rss-feed-url.ts | 13 +- 18 files changed, 843 insertions(+), 64 deletions(-) create mode 100644 apps/self-hosted/hosting/api/src/services/seo-files.test.ts create mode 100644 apps/self-hosted/hosting/api/src/services/seo-files.ts create mode 100644 apps/self-hosted/hosting/api/src/utils/escape-html.ts create mode 100644 apps/self-hosted/hosting/api/src/utils/excerpt.ts diff --git a/apps/self-hosted/hosting/api/package-lock.json b/apps/self-hosted/hosting/api/package-lock.json index 0396384ace..351f8f599c 100644 --- a/apps/self-hosted/hosting/api/package-lock.json +++ b/apps/self-hosted/hosting/api/package-lock.json @@ -8,6 +8,7 @@ "name": "@ecency/hosting-api", "version": "1.0.0", "dependencies": { + "@ecency/render-helper": "^2.5.26", "@ecency/sdk": "^2.2.0", "@hiveio/x402": "^0.1.2", "@hono/node-server": "^2.0.11", @@ -40,6 +41,25 @@ "node": ">=6" } }, + "node_modules/@ecency/render-helper": { + "version": "2.5.26", + "resolved": "https://registry.npmjs.org/@ecency/render-helper/-/render-helper-2.5.26.tgz", + "integrity": "sha512-n6nOXQYkX8VaS1bxE5fKbBX7B5Ro7xzZrbfqGLHZc8gNgVm0w8wTIHQUd9ObfXOzpzDZhnK2F9OwOVKHPy8bYA==", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "dom-serializer": "^2.0.0", + "he": "^1.2.0", + "htmlparser2": "^10.0.0", + "lolight": "^1.4.0", + "lru-cache": "^11.0.2", + "path": "^0.12.7", + "querystring": "^0.2.0", + "react-native-crypto-js": "^1.0.0", + "remarkable": "^2.0.1", + "url": "^0.11.0", + "xss": "^1.0.9" + } + }, "node_modules/@ecency/sdk": { "version": "2.3.83", "resolved": "https://registry.npmjs.org/@ecency/sdk/-/sdk-2.3.83.tgz", @@ -1183,6 +1203,14 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "engines": { + "node": ">=14.6" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1195,6 +1223,14 @@ "node": ">= 0.6" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1217,6 +1253,14 @@ "node": ">=12" } }, + "node_modules/autolinker": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz", + "integrity": "sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==", + "dependencies": { + "tslib": "^2.3.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1509,6 +1553,11 @@ "node": ">=0.10.0" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1592,6 +1641,11 @@ "node-fetch": "^2.7.0" } }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==" + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -1690,6 +1744,57 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/drbg.js": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/drbg.js/-/drbg.js-1.0.1.tgz", @@ -1768,6 +1873,17 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2168,6 +2284,14 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, "node_modules/hive-uri": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/hive-uri/-/hive-uri-0.2.8.tgz", @@ -2207,6 +2331,35 @@ "node": ">=16.9.0" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2445,6 +2598,11 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, + "node_modules/lolight": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/lolight/-/lolight-1.4.1.tgz", + "integrity": "sha512-AALiVBZpkryshtHbhE3KnRMGSnNtj7yfC2JgnMvXZ2sQQues4omu3pp04S+7X4P2QQhcXanPEXOGQgd77qMvJg==" + }, "node_modules/long": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", @@ -2459,6 +2617,14 @@ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2682,6 +2848,15 @@ "node": ">= 0.8" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -2916,6 +3091,14 @@ "node": ">=10" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -2942,6 +3125,11 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -2957,6 +3145,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystring": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", + "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -2999,6 +3196,11 @@ "rc": "cli.js" } }, + "node_modules/react-native-crypto-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-native-crypto-js/-/react-native-crypto-js-1.0.0.tgz", + "integrity": "sha512-FNbLuG/HAdapQoybeZSoes1PWdOj0w242gb+e1R0hicf3Gyj/Mf8M9NaED2AnXVOX01b2FXomwUiw1xP1K+8sA==" + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -3052,6 +3254,21 @@ "node": ">=4" } }, + "node_modules/remarkable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-2.0.1.tgz", + "integrity": "sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "^3.11.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -3385,6 +3602,11 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3569,6 +3791,11 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/tsx": { "version": "4.23.12", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", @@ -3650,6 +3877,18 @@ "node": ">= 0.8" } }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -3660,11 +3899,24 @@ "requires-port": "^1.0.0" } }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -3973,6 +4225,21 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/apps/self-hosted/hosting/api/package.json b/apps/self-hosted/hosting/api/package.json index bbae3d6da3..d4a1d68b1b 100644 --- a/apps/self-hosted/hosting/api/package.json +++ b/apps/self-hosted/hosting/api/package.json @@ -11,17 +11,18 @@ "test": "vitest run" }, "dependencies": { + "@ecency/render-helper": "^2.5.26", "@ecency/sdk": "^2.2.0", "@hiveio/x402": "^0.1.2", "@hono/node-server": "^2.0.11", "@hono/zod-validator": "^0.4.0", + "date-fns": "^3.6.0", "hono": "^4.4.0", "jsonwebtoken": "^9.0.0", + "nanoid": "^5.0.0", "pg": "^8.12.0", "redis": "^4.6.0", - "zod": "^3.23.0", - "date-fns": "^3.6.0", - "nanoid": "^5.0.0" + "zod": "^3.23.0" }, "devDependencies": { "@types/jsonwebtoken": "^9.0.0", diff --git a/apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts b/apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts index e3cf19a94c..760467524e 100644 --- a/apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts +++ b/apps/self-hosted/hosting/api/src/services/config-publish-lock.test.ts @@ -16,7 +16,10 @@ vi.mock('../db/client', () => ({ })); vi.mock('./tenant-service', () => ({ - TenantService: { getByUsername: mocks.getByUsername }, + TenantService: { + getByUsername: mocks.getByUsername, + getBlogUrl: (t: any) => `https://${t.username}.blogs.ecency.com`, + }, })); const { ConfigService } = await import('./config-service'); diff --git a/apps/self-hosted/hosting/api/src/services/config-service.ts b/apps/self-hosted/hosting/api/src/services/config-service.ts index 8b5d651ad2..67d677f90f 100644 --- a/apps/self-hosted/hosting/api/src/services/config-service.ts +++ b/apps/self-hosted/hosting/api/src/services/config-service.ts @@ -7,19 +7,23 @@ import { promises as fs } from 'fs'; import path from 'path'; import { withAdvisoryLock } from '../db/client'; +import { escapeHtml } from '../utils/escape-html'; import { TenantService, type Tenant } from './tenant-service'; +import { + buildRobotsTxt, + buildRssXml, + buildSitemapXml, + canonicalHomeUrl, + fetchTenantPosts, + SEO_FRESH_MS, +} from './seo-files'; const CONFIG_DIR = process.env.CONFIG_DIR || '/app/configs'; -/** Escape a string for safe interpolation into HTML text and attribute values. */ -export function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} +// Re-exported for existing consumers; the implementation lives in its own +// util so seo-files can use it without importing this module back +// (config-service consumes seo-files below). +export { escapeHtml } from '../utils/escape-html'; // Per-tenant write chains: every config-file write for a tenant runs strictly after the // previous one, so the periodic sync and a concurrent tenant update can never interleave. @@ -178,6 +182,11 @@ export const ConfigService = { ] : []), ``, + // Custom-domain tenants canonicalize to themselves, subdomain tenants + // to the ecency.com SSR page (see canonicalHomeUrl); the feed link is + // in the SSI snippet so crawlers see it, not only JS runtimes. + ``, + ``, ``, '', ].join('\n'); @@ -188,6 +197,56 @@ export const ConfigService = { return path.join(CONFIG_DIR, username.toLowerCase() + '.meta.html'); }, + /** Paths of a tenant's static SEO files (robots, sitemap, rss). */ + getSeoPaths(username: string): { robots: string; sitemap: string; rss: string } { + const base = path.join(CONFIG_DIR, username.toLowerCase()); + return { + robots: `${base}.robots.txt`, + sitemap: `${base}.sitemap.xml`, + rss: `${base}.rss.xml`, + }; + }, + + /** + * Regenerate a tenant's static SEO files when they are stale. Called from + * the sync pass inside the per-tenant lock. Freshness is mtime-based, so + * after an unchanged regeneration the files are touched: writeIfChanged + * deliberately skips identical writes, and without the touch every later + * pass would refetch the feed for a blog that has not posted. + */ + async writeSeoFilesIfStale(tenant: Tenant): Promise { + const paths = this.getSeoPaths(tenant.username); + const all = [paths.robots, paths.sitemap, paths.rss]; + + let fresh = true; + for (const p of all) { + try { + const stat = await fs.stat(p); + if (Date.now() - stat.mtimeMs > SEO_FRESH_MS) { + fresh = false; + break; + } + } catch { + fresh = false; + break; + } + } + if (fresh) return; + + // One bounded chain page; a failure here is caught by the sync pass's + // per-tenant isolation and yesterday's files keep serving. + const posts = await fetchTenantPosts(tenant); + await fs.mkdir(CONFIG_DIR, { recursive: true }); + await this.writeIfChanged(paths.robots, buildRobotsTxt(tenant)); + await this.writeIfChanged(paths.sitemap, buildSitemapXml(tenant, posts)); + await this.writeIfChanged(paths.rss, buildRssXml(tenant, posts)); + + const now = new Date(); + for (const p of all) { + await fs.utimes(p, now, now).catch(() => {}); + } + }, + /** * Delete config file for a tenant. Serialized through the same per-tenant lock as * writes, so an in-flight sync write cannot resurrect the file after deletion. @@ -198,7 +257,14 @@ export const ConfigService = { /** The actual unlink; only ever invoked under the per-tenant lock. */ async removeConfigFileUnlocked(username: string): Promise { - for (const filePath of [this.getConfigPath(username), this.getMetaPath(username)]) { + const seo = this.getSeoPaths(username); + for (const filePath of [ + this.getConfigPath(username), + this.getMetaPath(username), + seo.robots, + seo.sitemap, + seo.rss, + ]) { try { await fs.unlink(filePath); console.log('[ConfigService] Deleted config:', filePath); @@ -253,6 +319,9 @@ export const ConfigService = { for (const f of files) { if (f === 'default.json') continue; if (f.endsWith('.meta.html')) names.add(f.slice(0, -'.meta.html'.length)); + else if (f.endsWith('.robots.txt')) names.add(f.slice(0, -'.robots.txt'.length)); + else if (f.endsWith('.sitemap.xml')) names.add(f.slice(0, -'.sitemap.xml'.length)); + else if (f.endsWith('.rss.xml')) names.add(f.slice(0, -'.rss.xml'.length)); else if (f.endsWith('.json')) names.add(f.slice(0, -'.json'.length)); } return [...names]; @@ -298,6 +367,7 @@ export const ConfigService = { const fresh = await TenantService.getByUsername(tenant.username); if (!fresh || !isPublishableTenant(fresh)) return; await this.writeConfigFile(fresh); + await this.writeSeoFilesIfStale(fresh); }); } catch (err) { failed++; diff --git a/apps/self-hosted/hosting/api/src/services/hivesigner-registry.test.ts b/apps/self-hosted/hosting/api/src/services/hivesigner-registry.test.ts index 2b948dcdb8..e9431a6ed5 100644 --- a/apps/self-hosted/hosting/api/src/services/hivesigner-registry.test.ts +++ b/apps/self-hosted/hosting/api/src/services/hivesigner-registry.test.ts @@ -36,6 +36,7 @@ vi.mock('../db/client', () => ({ vi.mock('./tenant-service', () => ({ TenantService: { + getBlogUrl: (t: any) => `https://${t.username}.blogs.ecency.com`, getActiveTenants: mocks.getActiveTenants, applyConfigDocument: mocks.applyConfigDocument, getByUsername: mocks.getByUsername, diff --git a/apps/self-hosted/hosting/api/src/services/post-meta.test.ts b/apps/self-hosted/hosting/api/src/services/post-meta.test.ts index 7b9250e7da..2f2a90f131 100644 --- a/apps/self-hosted/hosting/api/src/services/post-meta.test.ts +++ b/apps/self-hosted/hosting/api/src/services/post-meta.test.ts @@ -74,8 +74,10 @@ describe('buildMetaForUri', () => { }); expect(html).not.toContain('