openvibers/openvibe.host Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#188)
Changes
@@@ -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 -->---@@@ -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"@@@ -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; }@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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',++ },++};nnew file mode 100644@@@ -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 };@@@ -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 })));nnew file mode 100644@@@ -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 };@@@ -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, actorLimiif (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, actorLimilet 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, confires.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, confiif (!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, confirouter.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, conficonst 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, conficonst 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);}@@@ -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 };
@@ -381,7 +381,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -381,7 +381,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.33.0
+- openvibe-shared: v4.34.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
---
---
@@ -15,11 +15,11 @@
@@ -15,11 +15,11 @@
"domain": "openvibe.host",
"domain": "openvibe.host",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.33.0",
+ "shared": "openvibe-shared v4.34.0",
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
"openvibe-sdk v0.67.0",
- "openvibe-shared v4.33.0"
+ "openvibe-shared v4.34.0"
],
],
"capabilities": [
"capabilities": [
"host.catalog.manage"
"host.catalog.manage"
@@ -57,6 +57,10 @@ server {
@@ -57,6 +57,10 @@ server {
proxy_pass http://127.0.0.1:4910;
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).
# Prometheus metrics: host-local scrapers only (http://127.0.0.1:4910/metrics).
location = /metrics { return 404; }
location = /metrics { return 404; }
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"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-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-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"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -32,6 +32,12 @@
@@ -32,6 +32,12 @@
"node": ">=22.0.0"
"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": {
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
@@ -510,6 +516,19 @@
@@ -510,6 +516,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/bowser": {
"version": "2.14.1",
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
@@ -618,6 +637,39 @@
@@ -618,6 +637,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"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": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -655,6 +707,73 @@
@@ -655,6 +707,73 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -705,6 +824,18 @@
@@ -705,6 +824,18 @@
"node": ">= 0.8"
"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": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -951,6 +1082,110 @@
@@ -951,6 +1082,110 @@
"node": ">= 0.4"
"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": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1096,6 +1331,30 @@
@@ -1096,6 +1331,30 @@
"safe-buffer": "^5.0.1"
"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": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -1234,6 +1493,22 @@
@@ -1234,6 +1493,22 @@
"node": ">= 0.6"
"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": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1321,12 +1596,13 @@
@@ -1321,12 +1596,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1775,6 +2051,12 @@
@@ -1775,6 +2051,12 @@
"node": ">= 0.6"
"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": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"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-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-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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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 };
@@ -10,11 +10,6 @@ const sharedMetrics = require('openvibe-shared/metrics');
@@ -10,11 +10,6 @@ const sharedMetrics = require('openvibe-shared/metrics');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const sharedSeo = require('openvibe-shared/seo');
const sharedSeo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
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 ovServe = require('openvibe-shared/serve');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
const configLib = require('./config');
const configLib = require('./config');
@@ -32,10 +27,8 @@ const { createCatalogSearch } = require('./catalog/search-index');
@@ -32,10 +27,8 @@ const { createCatalogSearch } = require('./catalog/search-index');
const { createRdap } = require('./catalog/rdap');
const { createRdap } = require('./catalog/rdap');
const { createDomainCheck } = require('./catalog/domain-check');
const { createDomainCheck } = require('./catalog/domain-check');
const { createBenchmarks } = require('./catalog/benchmarks');
const { createBenchmarks } = require('./catalog/benchmarks');
-const domainsRender = require('./render/domains');
const { createCatalogApi, createCatalogPages } = require('./http/catalog');
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 PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -84,9 +77,11 @@ async function createApp(opts = {}) {
@@ -84,9 +77,11 @@ async function createApp(opts = {}) {
const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });
const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });
const benchmarks = createBenchmarks({ store, baseUrl: config.baseUrl });
const benchmarks = createBenchmarks({ store, baseUrl: config.baseUrl });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
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(); });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
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 });
ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -129,7 +124,7 @@ async function createApp(opts = {}) {
@@ -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('/api/ready', createHostReadiness({ store, outbox, jwksUrl: `${config.networkInternalUrl}/api/.well-known/jwks`, release: release.release, valkey }).handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
app.get('/metrics', sharedMetrics.metricsHandler(registry));
const legalPaths = require('openvibe-shared/legal').PATHS;
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) => {
app.get('/sitemap.xml', async (_req, res, next) => {
try {
try {
const [providers, plans, tlds] = await Promise.all([catalog.providers(), catalog.sitemapPlans(), catalog.domains.sitemap()]);
const [providers, plans, tlds] = await Promise.all([catalog.providers(), catalog.sitemapPlans(), catalog.domains.sitemap()]);
@@ -186,6 +181,21 @@ async function createApp(opts = {}) {
@@ -186,6 +181,21 @@ async function createApp(opts = {}) {
app.locals.accountData = accountData;
app.locals.accountData = accountData;
app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
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('/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));
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 });
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 = {}) {
@@ -198,25 +208,16 @@ async function createApp(opts = {}) {
app.use('/catalog', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createCatalogPages(ctx));
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('/providers', (_req, res) => res.redirect(301, '/catalog/providers'));
app.get('/promotions', (_req, res) => res.redirect(301, '/catalog/promos'));
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) => {
app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
try {
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); }
} 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 })));
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 })));
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -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 };
@@ -3,10 +3,11 @@
@@ -3,10 +3,11 @@
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { ApiError, run, privateNoStore, sendError } = require('./errors');
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 render = require('../render/catalog');
const renderDomains = require('../render/domains');
const renderDomains = require('../render/domains');
const renderBench = require('../render/benchmarks');
const renderBench = require('../render/benchmarks');
+const { createCatalogViews } = require('./catalog-views');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { guard } = require('../auth/viewer');
const { guard } = require('../auth/viewer');
@@ -37,8 +38,11 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimi
@@ -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');
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 || {}) };
return { link: await benchmarks.createLink(req.viewer.subject, req.body || {}) };
}, 201));
}, 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) => {
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');
res.type('text/plain');
try {
try {
@@ -46,20 +50,20 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimi
@@ -46,20 +50,20 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimi
let body;
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.'); }
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);
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) {
} 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']) {
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); });
router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err); });
return router;
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();
const router = express.Router();
router.use(viewers.middleware('dashboard'));
router.use(viewers.middleware('dashboard'));
if (actorLimits) router.use(actorLimits);
if (actorLimits) router.use(actorLimits);
@@ -67,92 +71,51 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -67,92 +71,51 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
res.vary('Cookie'); res.vary('Authorization');
res.vary('Cookie'); res.vary('Authorization');
res.set('Cache-Control', cache.htmlHeaders(publicPage && req.viewer.kind === 'anonymous' ? { maxAge: 60 } : { private: true }));
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);
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) => {
const view = (fn) => async (req, res) => {
try { await fn(req, res); } catch (err) {
try { await fn(req, res); } catch (err) {
cachePage(req, res, false);
cachePage(req, res, false);
const status = err.status || 500;
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>'
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'] }));
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) => {
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');
if (typeof req.query.name !== 'string' || !req.query.name.trim()) return res.redirect(303, '/catalog/domains');
let c;
let c;
try { c = await domainCheck.check(req.query.name); } catch (err) {
try { c = await domainCheck.check(req.query.name); } catch (err) {
if (err.status !== 422) throw err;
if (err.status !== 422) throw err;
cachePage(req, res, false);
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) => {
router.get('/domains/:tld', view(async (req, res) => {
const t = await catalog.domains.tld(req.params.tld);
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 benchForm = async (req, res, { error = null, status = 200 } = {}) => {
const [providers, plans] = req.viewer.kind === 'user' ? await Promise.all([catalog.providers(), catalog.planChoices()]) : [[], []];
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));
const withPlans = providers.filter((p) => plans.some((x) => x.provider_slug === p.slug));
@@ -166,7 +129,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -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');
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;
let l;
try {
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) {
} catch (err) {
if (![422, 429, 503].includes(err.status)) throw err;
if (![422, 429, 503].includes(err.status)) throw err;
return benchForm(req, res, { error: err.message, status: err.status });
return benchForm(req, res, { error: err.message, status: err.status });
@@ -177,11 +140,15 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -177,11 +140,15 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
router.get('/benchmarks/:id', view(async (req, res) => {
router.get('/benchmarks/:id', view(async (req, res) => {
const b = await benchmarks.get(req.params.id, req.viewer);
const b = await benchmarks.get(req.params.id, req.viewer);
const canRemove = req.viewer.kind === 'user' && (b.mine || req.viewer.staff);
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) => {
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);
await benchmarks.remove(req.params.id, req.viewer);
res.redirect(303, '/catalog/benchmarks');
res.redirect(303, '/catalog/benchmarks');
}));
}));
@@ -191,10 +158,10 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -191,10 +158,10 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {
const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {
const rows = await catalog.all(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 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' });
const parser = express.urlencoded({ extended: false, limit: '16kb' });
router.post('/manage/:type/:id?', parser, view(async (req, res) => {
router.post('/manage/:type/:id?', parser, view(async (req, res) => {
@@ -205,7 +172,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -205,7 +172,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const body = { ...req.body }; delete body.csrf;
const body = { ...req.body }; delete body.csrf;
for (const k of Object.keys(body)) {
for (const k of Object.keys(body)) {
if (body[k] === '') { delete body[k]; continue; }
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 (['unmetered', 'affiliate'].includes(k)) body[k] = body[k] === 'true';
if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);
if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);
}
}
@@ -45,6 +45,22 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
@@ -45,6 +45,22 @@ const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
let RELEASE = 'dev';
let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
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 } */
/** o: title, body (HTML), viewer, config, path, notice { kind, text } */
function renderPage(o) {
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
@@ -74,8 +90,11 @@ function renderPage(o) {
@@ -74,8 +90,11 @@ function renderPage(o) {
// shell.page (openvibe-shared/shell) writes the document, the SEO head, the theme-loader, navbar.js and
// 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
// 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.
// 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({
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',
name: SITE_NAME, service: 'host', lang: 'en',
title: o.title || SITE_NAME,
title: o.title || SITE_NAME,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
@@ -112,4 +131,4 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -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 };