openvibers/openvibe.host Commits

Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#188)

OP OpenVibers committed 15 days ago · b02dac6a64 · 11 files, +618 −120

Changes

ddiff --git a/README.md b/README.md
iindex 8574d0a..b41c41e 100644
---- a/README.md
++++ b/README.md
@@@ -381,7 +381,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
--- openvibe-shared: v4.33.0
++- openvibe-shared: v4.34.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
---
ddiff --git a/STATUS.json b/STATUS.json
iindex 6940b3b..50e144a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -15,11 +15,11 @@
"domain": "openvibe.host",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.33.0",
++ "shared": "openvibe-shared v4.34.0",
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
-- "openvibe-shared v4.33.0"
++ "openvibe-shared v4.34.0"
],
"capabilities": [
"host.catalog.manage"
ddiff --git a/deploy/nginx/openvibe.host.conf b/deploy/nginx/openvibe.host.conf
iindex 46d38c6..ec85a86 100644
---- a/deploy/nginx/openvibe.host.conf
++++ b/deploy/nginx/openvibe.host.conf
@@@ -57,6 +57,10 @@ server {
proxy_pass http://127.0.0.1:4910;
}
++ # The agent layer (openvibe-shared/agent-layer): the manifest and the Markdown twin of every public page.
++ # GET only, and the exact path: a /.well-known/ prefix here would swallow the host's security.txt.
++ location = /.well-known/agent.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4910; }
++
# Prometheus metrics: host-local scrapers only (http://127.0.0.1:4910/metrics).
location = /metrics { return 404; }
ddiff --git a/package-lock.json b/package-lock.json
iindex eecaab2..1bb0ccc 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
},
"bin": {
@@@ -32,6 +32,12 @@
"node": ">=22.0.0"
}
},
++ "node_modules/@acemir/cssom": {
++ "version": "0.9.32",
++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
++ "license": "MIT"
++ },
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
@@@ -510,6 +516,19 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
@@@ -618,6 +637,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@@ -655,6 +707,73 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@@ -705,6 +824,18 @@
"node": ">= 0.8"
}
},
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "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",
@@@ -951,6 +1082,110 @@
"node": ">= 0.4"
}
},
++ "node_modules/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
++ "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"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
++ "node_modules/htmlparser2/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==",
++ "license": "MIT",
++ "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/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/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"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/htmlparser2/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/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==",
++ "license": "BSD-2-Clause",
++ "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/htmlparser2/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "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",
@@@ -1096,6 +1331,30 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/linkedom": {
++ "version": "0.18.14",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
++ "license": "ISC",
++ "dependencies": {
++ "@acemir/cssom": "^0.9.32",
++ "css-select": "^7.0.0",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@@ -1234,6 +1493,22 @@
"node": ">= 0.6"
}
},
++ "node_modules/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@@ -1321,12 +1596,13 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.33.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
-- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",
++ "version": "4.34.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"dependencies": {
-- "jsonwebtoken": "^9.0.0"
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
},
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
@@@ -1775,6 +2051,12 @@
"node": ">= 0.6"
}
},
++ "node_modules/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
ddiff --git a/package.json b/package.json
iindex 295e120..e9166b1 100644
---- a/package.json
++++ b/package.json
@@@ -34,7 +34,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..5cdf7a8
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,18 @@
++'use strict';
++// What openvibe.host tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves: the catalog API, the llms files, the sitemap and the
++// public pages that have a Markdown twin. The catalog publishes no OpenAPI document and has no docs page,
++// so neither is named.
++module.exports = {
++ id: 'host',
++ name: 'OpenVibe.Host',
++ purpose: 'Hosting plans and domain prices compared side by side: cloud, VPS and dedicated servers from every provider in the catalog, and what each registrar charges per domain ending, every price dated and linked to its source, with server benchmarks people ran on their own machines.',
++ home: 'https://openvibe.host',
++ site: 'openvibe.host',
++ api: '/api/v1/catalog',
++ docs: '/catalog',
++ policy: {
++ auth: ['none', 'Network OIDC'],
++ rateLimit: '300 page requests and 240 catalog API requests a minute per address; 30 domain name checks a minute; sign-in 60 attempts per 15 minutes',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..5701d03
---- /dev/null
++++ b/server/agent-pages.js
@@@ -0,0 +1,40 @@
++'use strict';
++// The Markdown twins of openvibe.host's public pages (openvibe-shared/agent-layer), rendered by the same
++// view builders the routes use (server/http/catalog-views.js and the front page's own builder), so a twin
++// cannot drift from its page. Anonymous only: the console, the manage pages, the domain name check and
++// the API are never rendered here.
++const { renderPage } = require('./render/layout');
++
++function createAgentPages(ctx, views) {
++ const v = views || require('./http/catalog-views').createCatalogViews(ctx);
++
++ const twin = (page, path) => (page ? {
++ html: renderPage({
++ ...page, config: ctx.config, path, indexable: page.indexable !== false,
++ viewer: { kind: 'anonymous' },
++ }),
++ } : null);
++
++ async function renderPublicPage(path, { query } = {}) {
++ const q = query ? Object.fromEntries(query.entries()) : {};
++ if (path === '/') return twin(await v.front(), '/');
++ if (path === '/catalog') return twin(await v.index(q), '/catalog');
++ if (path === '/catalog/providers') return twin(await v.providerList(), path);
++ if (path === '/catalog/promos') return twin(await v.promos(), path);
++ if (path === '/catalog/offers') return twin(await v.offers(q), path);
++ if (path === '/catalog/domains') return twin(await v.domains(q), path);
++ if (path === '/catalog/benchmarks') return twin(await v.benchmarkList(q), path);
++ if (path === '/updates') return twin(v.updates(), path);
++ let m = /^\/catalog\/providers\/([^/]+)$/.exec(path);
++ if (m) return twin(await v.provider(decodeURIComponent(m[1])), path);
++ m = /^\/catalog\/plans\/([^/]+)$/.exec(path);
++ if (m) return twin(await v.plan(decodeURIComponent(m[1])), path);
++ m = /^\/catalog\/domains\/([^/]+)$/.exec(path);
++ if (m) return twin(await v.domainTld(decodeURIComponent(m[1])), path);
++ return null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentPages };
ddiff --git a/server/app.js b/server/app.js
iindex 074b26f..64dadd3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,11 +10,6 @@ const sharedMetrics = require('openvibe-shared/metrics');
const cache = require('openvibe-shared/cache-policy');
const sharedSeo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
--const showcase = require('openvibe-shared/showcase');
--// The home's questions are Host's own FAQ in its service manifest (openvibe-contracts), shown and published as FAQPage.
--const HOST_FAQ = (((contracts.services.manifests.find((m) => m.id === 'host') || {}).site || {}).faq || []);
--const HOME_TIERS_GB = [1, 2, 4, 8, 16, 32];
--const HOME_DESCRIPTION = 'Cloud and VPS plans and domain prices compared side by side, per month, per vCPU and per GB, every price dated and linked to its source.';
const ovServe = require('openvibe-shared/serve');
const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
@@@ -32,10 +27,8 @@ const { createCatalogSearch } = require('./catalog/search-index');
const { createRdap } = require('./catalog/rdap');
const { createDomainCheck } = require('./catalog/domain-check');
const { createBenchmarks } = require('./catalog/benchmarks');
--const domainsRender = require('./render/domains');
const { createCatalogApi, createCatalogPages } = require('./http/catalog');
--const { renderPage, assetVersion, setRelease } = require('./render/layout');
--const catalogRender = require('./render/catalog');
++const { renderPage, assetVersion, setRelease, twinHeader } = require('./render/layout');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
@@@ -84,9 +77,11 @@ async function createApp(opts = {}) {
const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });
const benchmarks = createBenchmarks({ store, baseUrl: config.baseUrl });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
++ // The public pages as data, shared by the routes and the agent-layer twins (server/agent-pages.js).
++ const views = require('./http/catalog-views').createCatalogViews({ catalog, benchmarks, community, config, log });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
-- const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
++ const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };
ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });
const app = express();
app.disable('x-powered-by');
@@@ -129,7 +124,7 @@ async function createApp(opts = {}) {
app.get('/api/ready', createHostReadiness({ store, outbox, jwksUrl: `${config.networkInternalUrl}/api/.well-known/jwks`, release: release.release, valkey }).handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
const legalPaths = require('openvibe-shared/legal').PATHS;
-- app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Allow: /catalog$', 'Allow: /catalog/*', 'Disallow: /catalog/domains/check', 'Disallow: /catalog/benchmarks/', 'Allow: /catalog/benchmarks$', 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
++ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Allow: /catalog$', 'Allow: /catalog/*', 'Disallow: /catalog/domains/check', 'Disallow: /catalog/benchmarks/', 'Allow: /catalog/benchmarks$', 'Allow: /.well-known/agent.json', 'Allow: /*.md', 'Disallow: /', '', '# Agent manifest: https://openvibe.host/.well-known/agent.json; every public page has a .md twin.', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
app.get('/sitemap.xml', async (_req, res, next) => {
try {
const [providers, plans, tlds] = await Promise.all([catalog.providers(), catalog.sitemapPlans(), catalog.domains.sitemap()]);
@@@ -186,6 +181,21 @@ async function createApp(opts = {}) {
app.locals.accountData = accountData;
app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
}
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the
++ // public pages. Only the manifest and twin requests reach this mount, so every HTML page keeps the route
++ // it has — a signed-in visitor's own page, with the account bar and the private cache — and the page
++ // advertises its twin in its head and in the Link header. The console, the manage pages, the domain name
++ // check and the API are never rendered as twins.
++ const agentLayer = express();
++ require('openvibe-shared/agent-layer').mount(agentLayer, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-pages').createAgentPages(ctx, views).renderPublicPage,
++ });
++ app.use((req, res, next) => {
++ const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
++ if (req.path !== '/.well-known/agent.json' && !twin) return next();
++ return agentLayer(req, res, next);
++ });
app.use('/console', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), require('./console/pages').createConsolePages(ctx));
app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));
const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });
@@@ -198,25 +208,16 @@ async function createApp(opts = {}) {
app.use('/catalog', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createCatalogPages(ctx));
app.get('/providers', (_req, res) => res.redirect(301, '/catalog/providers'));
app.get('/promotions', (_req, res) => res.redirect(301, '/catalog/promos'));
-- app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true })));
++ app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true, agent: true })));
app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
try {
-- const [result, providers, promos, stats, recent, popular, domainStats, cheapDomains, providerCards] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}), catalog.domains.list({}), catalog.domains.stats(), catalog.domains.list({ sort: 'register' }), catalog.providerSummary()]);
-- // The lowest price for each size, and the most each dollar buys: the same plan query the catalog page runs.
-- const first = async (q) => (await catalog.plans(q)).plans[0] || null;
-- const tiers = (await Promise.all(HOME_TIERS_GB.map(async (gb) => ({ gb, plan: await first({ min_ram_mb: gb * 1024 }) }))))
-- .filter((x, i, all) => x.plan && !(i > 0 && all[i - 1].plan && all[i - 1].plan.id === x.plan.id)); // a size the smaller one already covers adds nothing
-- const [vcpu, ram, disk] = await Promise.all([first({ sort: 'price_per_vcpu' }), first({ sort: 'price_per_gb_ram' }), first({ sort: 'price_per_gb_disk' })]);
-- const more = {
-- counts: [
-- { value: stats.plans, label: 'hosting plans compared', href: '/catalog' },
-- { value: stats.providers, label: stats.providers === 1 ? 'provider' : 'providers', href: '/catalog/providers' },
-- { value: domainStats.tlds, label: 'domain endings priced', href: '/catalog/domains' },
-- { value: (domainStats.registrars || []).length, label: (domainStats.registrars || []).length === 1 ? 'registrar priced' : 'registrars compared', href: '/catalog/domains' },
-- ],
-- tiers, value: { vcpu, ram, disk }, providerCards, faq: HOST_FAQ,
-- };
-- res.type('html').send(renderPage({ title: 'Hosting plans and domain prices, compared', description: HOME_DESCRIPTION, body: catalogRender.front({ plans: result.plans, providers, promos, stats, offers: recent.offers, more, domains: domainsRender.frontSection({ popular: popular.tlds, cheap: cheapDomains.tlds, stats: domainStats }) }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: [...catalogRender.jsonLd.front(config.baseUrl, result.plans), ...(HOST_FAQ.length ? [sharedSeo.jsonLd.faq(HOST_FAQ.map((f) => ({ q: f.question, a: f.answer })))] : [])] }));
++ // The front page is built by the shared view builder (server/http/catalog-views.js), the same one
++ // the agent-layer twin uses, so the two cannot drift.
++ const view = await views.front();
++ const o = { title: view.title, description: view.description, body: view.body, viewer: req.viewer, config, path: '/', indexable: true, styles: view.styles, css: view.css, jsonLd: view.jsonLd, agent: true };
++ const link = twinHeader(o);
++ if (link) res.set('Link', link);
++ res.type('html').send(renderPage(o));
} catch (err) { next(err); }
});
app.use((req, res) => req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }) : res.status(404).type('html').send(renderPage({ title: 'Not found', body: '<h1>Not found</h1>', config, path: req.originalUrl })));
ddiff --git a/server/http/catalog-views.js b/server/http/catalog-views.js
nnew file mode 100644
iindex 0000000..0ebe74b
---- /dev/null
++++ b/server/http/catalog-views.js
@@@ -0,0 +1,167 @@
++'use strict';
++/**
++ * The public catalog pages as data (title, body, indexable, description, JSON-LD), shared by the routes
++ * (server/http/catalog.js and the front page in server/app.js) and the agent-layer twins
++ * (server/agent-pages.js), so a twin cannot drift from its page. Every view is signed out: it renders
++ * what an anonymous visitor sees.
++ *
++ * The discussion block on a provider or plan page comes from OpenVibe.Community; the route passes the
++ * request's context so the thread can be read, the twin does not (a twin never opens a community thread).
++ */
++const contracts = require('openvibe-contracts');
++const sharedSeo = require('openvibe-shared/seo');
++const showcase = require('openvibe-shared/showcase');
++const frame = require('openvibe-shared/frame');
++const ovServe = require('openvibe-shared/serve');
++const render = require('../render/catalog');
++const renderDomains = require('../render/domains');
++const renderBench = require('../render/benchmarks');
++
++// The home's questions are Host's own FAQ in its service manifest (openvibe-contracts), shown and published as FAQPage.
++const HOST_FAQ = (((contracts.services.manifests.find((m) => m.id === 'host') || {}).site || {}).faq || []);
++const HOME_TIERS_GB = [1, 2, 4, 8, 16, 32];
++const HOME_DESCRIPTION = 'Cloud and VPS plans and domain prices compared side by side, per month, per vCPU and per GB, every price dated and linked to its source.';
++
++function createCatalogViews({ catalog, benchmarks, community, config, log = console }) {
++ const watchUrl = config.watch && config.watch.publicUrl;
++ const baseUrl = config.baseUrl;
++
++ /** The community thread of a provider or plan, or null when Community is off or unreachable. */
++ async function discussionFor(ctx, type, entity, label) {
++ if (!community || !community.enabled) return null;
++ try {
++ const threadId = await community.threadFor(type, entity, label, ctx);
++ const data = await community.readThread(threadId, ctx);
++ if (!data || !data.thread) throw new Error('Community returned no thread');
++ return { state: 'ok', thread: data.thread, comments: Array.isArray(data.comments) ? data.comments : [], communityUrl: community.publicUrl };
++ } catch (err) {
++ log.warn('[Host] ' + type + ' ' + entity.id + ' discussion unavailable: ' + err.message);
++ return { state: 'unavailable' };
++ }
++ }
++
++ /** The front page: the same hero, tables, offers, promos, domain section and FAQ the route renders. */
++ async function front() {
++ const [result, providers, promos, stats, recent, popular, domainStats, cheapDomains, providerCards] = await Promise.all([
++ catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}),
++ catalog.domains.list({}), catalog.domains.stats(), catalog.domains.list({ sort: 'register' }), catalog.providerSummary(),
++ ]);
++ // The lowest price for each size, and the most each dollar buys: the same plan query the catalog page runs.
++ const first = async (q) => (await catalog.plans(q)).plans[0] || null;
++ const tiers = (await Promise.all(HOME_TIERS_GB.map(async (gb) => ({ gb, plan: await first({ min_ram_mb: gb * 1024 }) }))))
++ .filter((x, i, all) => x.plan && !(i > 0 && all[i - 1].plan && all[i - 1].plan.id === x.plan.id));
++ const [vcpu, ram, disk] = await Promise.all([first({ sort: 'price_per_vcpu' }), first({ sort: 'price_per_gb_ram' }), first({ sort: 'price_per_gb_disk' })]);
++ const more = {
++ counts: [
++ { value: stats.plans, label: 'hosting plans compared', href: '/catalog' },
++ { value: stats.providers, label: stats.providers === 1 ? 'provider' : 'providers', href: '/catalog/providers' },
++ { value: domainStats.tlds, label: 'domain endings priced', href: '/catalog/domains' },
++ { value: (domainStats.registrars || []).length, label: (domainStats.registrars || []).length === 1 ? 'registrar priced' : 'registrars compared', href: '/catalog/domains' },
++ ],
++ tiers, value: { vcpu, ram, disk }, providerCards, faq: HOST_FAQ,
++ };
++ return {
++ title: 'Hosting plans and domain prices, compared',
++ description: HOME_DESCRIPTION,
++ body: render.front({
++ plans: result.plans, providers, promos, stats, offers: recent.offers, more,
++ domains: renderDomains.frontSection({ popular: popular.tlds, cheap: cheapDomains.tlds, stats: domainStats }),
++ }),
++ indexable: true,
++ styles: [showcase.STYLESHEET],
++ css: ['css/catalog.css'],
++ jsonLd: [...render.jsonLd.front(baseUrl, result.plans), ...(HOST_FAQ.length ? [sharedSeo.jsonLd.faq(HOST_FAQ.map((f) => ({ q: f.question, a: f.answer })))] : [])],
++ };
++ }
++
++ /** The public update log. */
++ function updates() {
++ return {
++ title: 'What shipped on OpenVibe.Host', indexable: true,
++ body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + '<script src="' + ovServe.url('shipped.js') + '" defer></script>',
++ };
++ }
++
++ async function index(query = {}) {
++ const [result, providers, currencies] = await Promise.all([catalog.plans(query), catalog.providers(), catalog.currencies()]);
++ return {
++ title: 'Compare hosting plans',
++ body: render.catalog({ result, query, providers, currencies }),
++ indexable: !Object.keys(query).length,
++ description: 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
++ jsonLd: render.jsonLd.catalog(baseUrl, result.plans),
++ };
++ }
++
++ async function providerList() {
++ return { title: 'Hosting providers', body: render.providerList(await catalog.providers()), indexable: true };
++ }
++
++ async function provider(slug, ctx) {
++ const p = await catalog.providers(slug);
++ const [discussion, bench] = await Promise.all([discussionFor(ctx, 'provider', p, p.name), benchmarks.summary({ providerId: p.id })]);
++ const benchHtml = p.plans.length || bench.count ? renderBench.summaryBlock(bench, { href: '/catalog/benchmarks?provider=' + encodeURIComponent(p.slug), label: p.name }) : '';
++ return {
++ title: p.name + ' plans and prices', body: render.provider(p, discussion, benchHtml), indexable: true,
++ description: p.name + "'s hosting plans compared: monthly prices, per-resource prices and the day each was checked.",
++ };
++ }
++
++ async function plan(id, ctx) {
++ const p = await catalog.plan(id);
++ const [discussion, bench] = await Promise.all([discussionFor(ctx, 'plan', p, p.provider_name + ' ' + p.name), benchmarks.summary({ planId: p.id })]);
++ const benchHtml = renderBench.summaryBlock(bench, { href: '/catalog/benchmarks?plan=' + encodeURIComponent(p.id), label: p.provider_name + ' ' + p.name });
++ return {
++ title: p.provider_name + ' ' + p.name,
++ body: render.plan(p, discussion, { baseUrl, watchUrl, extra: benchHtml }),
++ indexable: true,
++ description: p.provider_name + ' ' + p.name + ': ' + p.currency + ' ' + p.price_monthly + ' a month, with its specifications, price per resource and price history.',
++ jsonLd: render.jsonLd.plan(baseUrl, p),
++ };
++ }
++
++ async function promos() {
++ return { title: 'Hosting promotions', body: render.promos(await catalog.promos()), indexable: true, description: 'Hosting promotions running now, with their codes and end dates as the providers state them.' };
++ }
++
++ async function offers(query = {}) {
++ // Sorting by price stays within one currency (nothing is converted): the page picks USD when none is chosen.
++ const q = { ...query };
++ if (q.sort === 'price' && !q.currency) q.currency = 'USD';
++ const result = await catalog.offers(q);
++ return {
++ title: 'Hosting offers', body: render.offers({ result, query: q }), indexable: !Object.keys(query).length,
++ description: "Hosting offers from LowEndSpirit's Offers board, newest first or cheapest a month, each with the price its headline states and the day it was posted.",
++ };
++ }
++
++ async function domains(query = {}) {
++ const [result, stats] = await Promise.all([catalog.domains.list(query), catalog.domains.stats()]);
++ return {
++ title: 'Compare domain prices', body: renderDomains.index({ result, query, stats, registrars: stats.registrars }), indexable: !Object.keys(query).length,
++ description: 'Domain prices compared: what registrars charge for each ending for the first year, a renewal and a transfer, five years added up, and a name check at the registry.',
++ jsonLd: renderDomains.jsonLd.index(baseUrl, result.tlds),
++ };
++ }
++
++ async function domainTld(tld) {
++ const t = await catalog.domains.tld(tld);
++ return {
++ title: t.display + ' domain prices', body: renderDomains.tld(t, { baseUrl, watchUrl }), indexable: true,
++ description: t.display + ' domains from ' + (render.money(t.cheapest.register, 'USD') || 'an unknown price') + ' the first year, renewing from ' + (render.money(t.cheapest.renew, 'USD') || 'an unknown price') + ": every registrar's price, dated and sourced.",
++ jsonLd: renderDomains.jsonLd.tld(baseUrl, t),
++ };
++ }
++
++ async function benchmarkList(query = {}) {
++ const result = await benchmarks.list(query);
++ return {
++ title: 'Server benchmarks', body: renderBench.list({ result, query }), indexable: !Object.keys(query).length,
++ description: 'CPU, disk and network benchmarks people ran on their own VPS and dedicated servers with YABS: Geekbench, fio and iperf3, by provider and plan.',
++ };
++ }
++
++ return { front, updates, index, providerList, provider, plan, promos, offers, domains, domainTld, benchmarkList };
++}
++
++module.exports = { createCatalogViews, HOME_DESCRIPTION, HOST_FAQ };
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex 184e4ef..c1dd814 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -3,10 +3,11 @@
const express = require('express');
const contracts = require('openvibe-contracts');
const { ApiError, run, privateNoStore, sendError } = require('./errors');
--const { renderPage, esc } = require('../render/layout');
++const { renderPage, esc, twinHeader } = require('../render/layout');
const render = require('../render/catalog');
const renderDomains = require('../render/domains');
const renderBench = require('../render/benchmarks');
++const { createCatalogViews } = require('./catalog-views');
const rateLimit = require('express-rate-limit');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { guard } = require('../auth/viewer');
@@@ -37,8 +38,11 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimi
if (req.viewer.kind !== 'user') throw new ApiError(401, 'host.benchmark.sign_in', 'Sign in to submit a benchmark');
return { link: await benchmarks.createLink(req.viewer.subject, req.body || {}) };
}, 201));
-- router.delete('/benchmarks/:id', run(async (req) => { await benchmarks.remove(req.params.id, req.viewer); return { removed: true }; }));
-- // YABS posts its JSON here (`-s <url>`) and prints the answer in the terminal, so the answer is one line of text.
++ router.delete('/benchmarks/:id', run(async (req) => {
++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'host.benchmark.sign_in', 'Sign in to submit a benchmark');
++ await benchmarks.remove(req.params.id, req.viewer);
++ return { removed: true };
++ }));
router.post('/benchmarks/yabs/:token', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), express.text({ type: () => true, limit: '128kb' }), async (req, res) => {
res.type('text/plain');
try {
@@@ -46,20 +50,20 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimi
let body;
try { body = JSON.parse(typeof req.body === 'string' ? req.body : ''); } catch { throw new ApiError(400, 'host.benchmark.invalid', 'The result was not valid JSON; nothing was saved.'); }
const saved = await benchmarks.submit(req.params.token, body);
-- res.status(201).send(`\nOpenVibe.Host saved this result: ${saved.url}\n`);
++ res.status(201).send('\nOpenVibe.Host saved this result: ' + saved.url + '\n');
} catch (err) {
-- if (!err.status || err.status >= 500) log.warn(`[Host] benchmark submit failed: ${err.message}`);
-- res.status(err.status && err.status < 500 ? err.status : 500).send(`\nOpenVibe.Host did not save this result: ${err.status && err.status < 500 ? err.message : 'something went wrong on our side'}\n`);
++ if (!err.status || err.status >= 500) log.warn('[Host] benchmark submit failed: ' + err.message);
++ res.status(err.status && err.status < 500 ? err.status : 500).send('\nOpenVibe.Host did not save this result: ' + (err.status && err.status < 500 ? err.message : 'something went wrong on our side') + '\n');
}
});
for (const type of ['providers', 'plans', 'promos']) {
-- router.post(`/${type}`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, null, req.viewer.subject) }), 201));
-- router.patch(`/${type}/:id`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, req.params.id, req.viewer.subject) })));
++ router.post('/' + type, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, null, req.viewer.subject) }), 201));
++ router.patch('/' + type + '/:id', guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, req.params.id, req.viewer.subject) })));
}
router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err); });
return router;
}
--function createCatalogPages({ catalog, domainCheck, benchmarks, community, config, viewers, actorLimits, log = console }) {
++function createCatalogPages({ catalog, domainCheck, benchmarks, community, config, viewers, actorLimits, views: given, log = console }) {
const router = express.Router();
router.use(viewers.middleware('dashboard'));
if (actorLimits) router.use(actorLimits);
@@@ -67,92 +71,51 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
res.vary('Cookie'); res.vary('Authorization');
res.set('Cache-Control', cache.htmlHeaders(publicPage && req.viewer.kind === 'anonymous' ? { maxAge: 60 } : { private: true }));
};
-- const page = (req, res, title, body, indexable = true, description, ld) => {
++ // The public pages are rendered by the shared view builders (catalog-views.js), the same ones the
++ // agent-layer twins use, so a twin cannot drift from its page. A page with a twin advertises it in
++ // its head and in the Link header.
++ const views = given || createCatalogViews({ catalog, benchmarks, community, config, log });
++ const page = (req, res, view) => {
++ const indexable = view.indexable !== false;
cachePage(req, res, indexable);
-- return res.status(200).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, indexable, description, jsonLd: ld, css: ['css/catalog.css'] }));
++ const o = { title: view.title, body: view.body, viewer: req.viewer, config, path: req.originalUrl, indexable, description: view.description, jsonLd: view.jsonLd, css: view.css || ['css/catalog.css'], styles: view.styles, agent: indexable };
++ const link = twinHeader(o);
++ if (link) res.set('Link', link);
++ return res.status(200).type('html').send(renderPage(o));
};
-- async function discussionFor(req, type, entity, label) {
-- if (!community || !community.enabled) return null;
-- try {
-- const threadId = await community.threadFor(type, entity, label, req.ov);
-- const data = await community.readThread(threadId, req.ov);
-- if (!data || !data.thread) throw new Error('Community returned no thread');
-- return { state: 'ok', thread: data.thread, comments: Array.isArray(data.comments) ? data.comments : [], communityUrl: community.publicUrl };
-- } catch (err) {
-- log.warn(`[Host] ${type} ${entity.id} discussion unavailable: ${err.message}`);
-- return { state: 'unavailable' };
-- }
-- }
const view = (fn) => async (req, res) => {
try { await fn(req, res); } catch (err) {
cachePage(req, res, false);
const status = err.status || 500;
const body = status === 404 ? '<h1>Not in the catalog</h1><p>This entry is not listed (or no longer is).</p><p><a href="/catalog">Compare plans</a></p>'
-- : status === 422 ? `<h1>That filter did not work</h1><p>${esc(err.message)}</p><p><a href="/catalog">Clear the filters</a></p>`
-- : `<h1>Catalog error</h1><p>${esc(err.message)}</p>`;
++ : status === 422 ? '<h1>That filter did not work</h1><p>' + esc(err.message) + '</p><p><a href="/catalog">Clear the filters</a></p>'
++ : '<h1>Catalog error</h1><p>' + esc(err.message) + '</p>';
res.status(status).type('html').send(renderPage({ title: 'Catalog', body, viewer: req.viewer, config, path: req.originalUrl, css: ['css/catalog.css'] }));
}
};
-- router.get('/', view(async (req, res) => {
-- const [result, providers, currencies] = await Promise.all([catalog.plans(req.query), catalog.providers(), catalog.currencies()]);
-- page(req, res, 'Compare hosting plans', render.catalog({ result, query: req.query, providers, currencies }), !Object.keys(req.query).length,
-- 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.',
-- render.jsonLd.catalog(config.baseUrl, result.plans));
-- }));
-- router.get('/providers', view(async (req, res) => {
-- page(req, res, 'Hosting providers', render.providerList(await catalog.providers()));
-- }));
-- router.get('/providers/:slug', view(async (req, res) => {
-- const p = await catalog.providers(req.params.slug);
-- const [discussion, bench] = await Promise.all([discussionFor(req, 'provider', p, p.name), benchmarks.summary({ providerId: p.id })]);
-- const benchHtml = p.plans.length || bench.count ? renderBench.summaryBlock(bench, { href: `/catalog/benchmarks?provider=${encodeURIComponent(p.slug)}`, label: p.name }) : '';
-- page(req, res, `${p.name} plans and prices`, render.provider(p, discussion, benchHtml), true, `${p.name}'s hosting plans compared: monthly prices, per-resource prices and the day each was checked.`);
-- }));
-- router.get('/plans/:id', view(async (req, res) => {
-- const p = await catalog.plan(req.params.id);
-- const [discussion, bench] = await Promise.all([discussionFor(req, 'plan', p, `${p.provider_name} ${p.name}`), benchmarks.summary({ planId: p.id })]);
-- const benchHtml = renderBench.summaryBlock(bench, { href: `/catalog/benchmarks?plan=${encodeURIComponent(p.id)}`, label: `${p.provider_name} ${p.name}` });
-- page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion, { baseUrl: config.baseUrl, watchUrl: config.watch && config.watch.publicUrl, extra: benchHtml }), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`, render.jsonLd.plan(config.baseUrl, p));
-- }));
-- router.get('/promos', view(async (req, res) => {
-- page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
-- }));
-- router.get('/offers', view(async (req, res) => {
-- // Sorting by price stays within one currency (nothing is converted): the page picks USD when none is chosen.
-- const query = { ...req.query };
-- if (query.sort === 'price' && !query.currency) query.currency = 'USD';
-- const result = await catalog.offers(query);
-- page(req, res, 'Hosting offers', render.offers({ result, query }), !Object.keys(req.query).length,
-- 'Hosting offers from LowEndSpirit\'s Offers board, newest first or cheapest a month, each with the price its headline states and the day it was posted.');
-- }));
-- router.get('/domains', view(async (req, res) => {
-- const [result, stats] = await Promise.all([catalog.domains.list(req.query), catalog.domains.stats()]);
-- page(req, res, 'Compare domain prices', renderDomains.index({ result, query: req.query, stats, registrars: stats.registrars }), !Object.keys(req.query).length,
-- 'Domain prices compared: what registrars charge for each ending for the first year, a renewal and a transfer, five years added up, and a name check at the registry.',
-- renderDomains.jsonLd.index(config.baseUrl, result.tlds));
-- }));
++ router.get('/', view(async (req, res) => page(req, res, await views.index(req.query))));
++ router.get('/providers', view(async (req, res) => page(req, res, await views.providerList())));
++ router.get('/providers/:slug', view(async (req, res) => page(req, res, await views.provider(req.params.slug, req.ov))));
++ router.get('/plans/:id', view(async (req, res) => page(req, res, await views.plan(req.params.id, req.ov))));
++ router.get('/promos', view(async (req, res) => page(req, res, await views.promos())));
++ router.get('/offers', view(async (req, res) => page(req, res, await views.offers(req.query))));
++ router.get('/domains', view(async (req, res) => page(req, res, await views.domains(req.query))));
router.get('/domains/check', checkLimit(), view(async (req, res) => {
if (typeof req.query.name !== 'string' || !req.query.name.trim()) return res.redirect(303, '/catalog/domains');
let c;
try { c = await domainCheck.check(req.query.name); } catch (err) {
if (err.status !== 422) throw err;
cachePage(req, res, false);
-- return res.status(422).type('html').send(renderPage({ title: 'Check a domain name', body: `<h1>That is not a domain name</h1><p>${esc(err.message)}</p><p><a href="/catalog/domains">Check another name</a></p>`, viewer: req.viewer, config, path: req.originalUrl, css: ['css/catalog.css'] }));
++ return res.status(422).type('html').send(renderPage({ title: 'Check a domain name', body: '<h1>That is not a domain name</h1><p>' + esc(err.message) + '</p><p><a href="/catalog/domains">Check another name</a></p>', viewer: req.viewer, config, path: req.originalUrl, css: ['css/catalog.css'] }));
}
-- page(req, res, `${c.display}: is it taken?`, renderDomains.check(c), false, `Whether ${c.display} is registered, and what it costs at each registrar.`);
++ page(req, res, { title: c.display + ': is it taken?', body: renderDomains.check(c), indexable: false, description: 'Whether ' + c.display + ' is registered, and what it costs at each registrar.' });
}));
router.get('/domains/:tld', view(async (req, res) => {
const t = await catalog.domains.tld(req.params.tld);
-- if (req.params.tld !== t.tld) return res.redirect(301, `/catalog/domains/${encodeURIComponent(t.tld)}`);
-- page(req, res, `${t.display} domain prices`, renderDomains.tld(t, { baseUrl: config.baseUrl, watchUrl: config.watch && config.watch.publicUrl }), true,
-- `${t.display} domains from ${render.money(t.cheapest.register, 'USD') || 'an unknown price'} the first year, renewing from ${render.money(t.cheapest.renew, 'USD') || 'an unknown price'}: every registrar's price, dated and sourced.`,
-- renderDomains.jsonLd.tld(config.baseUrl, t));
-- }));
-- router.get('/benchmarks', view(async (req, res) => {
-- const result = await benchmarks.list(req.query);
-- page(req, res, 'Server benchmarks', renderBench.list({ result, query: req.query }), !Object.keys(req.query).length,
-- 'CPU, disk and network benchmarks people ran on their own VPS and dedicated servers with YABS: Geekbench, fio and iperf3, by provider and plan.');
++ if (req.params.tld !== t.tld) return res.redirect(301, '/catalog/domains/' + encodeURIComponent(t.tld));
++ page(req, res, await views.domainTld(req.params.tld));
}));
++ router.get('/benchmarks', view(async (req, res) => page(req, res, await views.benchmarkList(req.query))));
const benchForm = async (req, res, { error = null, status = 200 } = {}) => {
const [providers, plans] = req.viewer.kind === 'user' ? await Promise.all([catalog.providers(), catalog.planChoices()]) : [[], []];
const withPlans = providers.filter((p) => plans.some((x) => x.provider_slug === p.slug));
@@@ -166,7 +129,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body.csrf)) throw new ApiError(403, 'host.catalog.forbidden', 'Invalid form token or origin');
let l;
try {
-- l = await benchmarks.createLink(req.viewer.subject, { provider: req.body.provider || undefined, provider_name: req.body.provider_name, plan_id: req.body.plan_id || undefined, note: req.body.note });
++ l = await benchmarks.createLink(req.viewer.subject, req.body || {});
} catch (err) {
if (![422, 429, 503].includes(err.status)) throw err;
return benchForm(req, res, { error: err.message, status: err.status });
@@@ -177,11 +140,15 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
router.get('/benchmarks/:id', view(async (req, res) => {
const b = await benchmarks.get(req.params.id, req.viewer);
const canRemove = req.viewer.kind === 'user' && (b.mine || req.viewer.staff);
-- page(req, res, `${b.provider_name}${b.plan_name ? ` ${b.plan_name}` : ''} benchmark`, renderBench.detail(b, { csrf: canRemove ? csrfToken(config, req.viewer) : '', canRemove }), false,
-- `A YABS benchmark of a ${b.provider_name} server: Geekbench, disk and network results.`);
++ page(req, res, {
++ title: b.provider_name + (b.plan_name ? ' ' + b.plan_name : '') + ' benchmark', indexable: false,
++ body: renderBench.detail(b, { csrf: canRemove ? csrfToken(config, req.viewer) : '', canRemove }),
++ description: 'A YABS benchmark of a ' + b.provider_name + ' server: Geekbench, disk and network results.',
++ });
}));
router.post('/benchmarks/:id/remove', benchParser, view(async (req, res) => {
-- if (req.viewer.kind !== 'user' || !sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body.csrf)) throw new ApiError(403, 'host.catalog.forbidden', 'Invalid form token or origin');
++ if (req.viewer.kind !== 'user') throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body.csrf)) throw new ApiError(403, 'host.catalog.forbidden', 'Invalid form token or origin');
await benchmarks.remove(req.params.id, req.viewer);
res.redirect(303, '/catalog/benchmarks');
}));
@@@ -191,10 +158,10 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {
const rows = await catalog.all(type);
const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];
-- const form = (row) => `<form method="post" action="/catalog/manage/${type}${row ? `/${esc(row.id)}` : ''}"><input type="hidden" name="csrf" value="${csrf}">${keys.map((k) => `<label>${esc(k)} <input name="${k}" value="${esc(row && row[k] != null ? Array.isArray(row[k]) ? row[k].join(',') : row[k] : '')}"></label>`).join('')}<button>${row ? 'Save' : 'Add'}</button></form>`;
-- return `<section><h2>${type}</h2>${form(null)}${rows.map((r) => `<h3>${esc(r.name || r.title || r.id)}</h3>${form(r)}`).join('')}</section>`;
++ const form = (row) => '<form method="post" action="/catalog/manage/' + type + (row ? '/' + esc(row.id) : '') + '"><input type="hidden" name="csrf" value="' + csrf + '">' + keys.map((k) => '<label>' + esc(k) + ' <input name="' + k + '" value="' + esc(row && row[k] != null ? Array.isArray(row[k]) ? row[k].join(',') : row[k] : '') + '"></label>').join('') + '<button>Save</button></form>';
++ return '<section><h2>' + type + '</h2>' + form(null) + rows.map((r) => '<h3>' + esc(r.name || r.title || r.id) + '</h3>' + form(r)).join('') + '</section>';
}));
-- page(req, res, 'Manage catalog', `<h1>Manage catalog</h1>${blocks.join('')}`, false);
++ page(req, res, { title: 'Manage catalog', body: '<h1>Manage catalog</h1>' + blocks.join(''), indexable: false });
}));
const parser = express.urlencoded({ extended: false, limit: '16kb' });
router.post('/manage/:type/:id?', parser, view(async (req, res) => {
@@@ -205,7 +172,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const body = { ...req.body }; delete body.csrf;
for (const k of Object.keys(body)) {
if (body[k] === '') { delete body[k]; continue; }
-- if (['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);
++ if (['provider_id', 'vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);
if (['unmetered', 'affiliate'].includes(k)) body[k] = body[k] === 'true';
if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 65c74c0..9ff2d8f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -45,6 +45,22 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
++/** The canonical URL of a page, or undefined when the page is not indexable. */
++function canonicalOf(o) {
++ return o.indexable ? o.config.baseUrl + (o.path && o.path.split('?')[0] !== '/' ? o.path.split('?')[0] : '/') : undefined;
++}
++
++/**
++ * The discovery links for a page that has a Markdown twin (openvibe-shared/agent-layer): the twin's own
++ * URL in the Link header. The twin itself is server/agent-pages.js.
++ */
++function twinHeader(o) {
++ const canonical = canonicalOf(o);
++ if (!canonical) return null;
++ const llms = String(o.config.baseUrl).replace(/\/+$/, '') + '/llms.txt';
++ return require('openvibe-shared/agent-layer').linkHeaderTags(canonical, llms).header;
++}
++
/** o: title, body (HTML), viewer, config, path, notice { kind, text } */
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
@@@ -74,8 +90,11 @@ function renderPage(o) {
// shell.page (openvibe-shared/shell) writes the document, the SEO head, the theme-loader, navbar.js and
// footer.js with the navbar init, the noscript nav and the SSR footer; the rest of the head and the body
// are the catalog's own pages.
-- const canonical = o.indexable ? `${o.config.baseUrl}${o.path && o.path.split('?')[0] !== '/' ? o.path.split('?')[0] : '/'}` : undefined;
++ const canonical = canonicalOf(o);
return shell.page({
++ // A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.
++ agentLayer: !!o.agent,
++ llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,
name: SITE_NAME, service: 'host', lang: 'en',
title: o.title || SITE_NAME,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
@@@ -112,4 +131,4 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
--module.exports = { renderPage, esc, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL };
++module.exports = { renderPage, esc, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, canonicalOf, twinHeader };