openvibers/openvibe.host Commits

Host: server benchmarks people run with YABS (one-time links, Geekbench, fio, iperf3), with medians on plan and provider pages, at /catalog/benchmarks (#117)

OP OpenVibers committed 25 days ago · 8b50499632 · 16 files, +688 −21

Changes

ddiff --git a/README.md b/README.md
iindex cd66b19..456da3d 100644
---- a/README.md
++++ b/README.md
@@@ -26,12 +26,13 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- Nothing is estimated. A value the source does not state is shown as Unknown.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.
++- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
- **Search.** Every public plan and provider page is an OpenVibe.Search document (`host.index_document.upserted|deleted`, `server/catalog/search-index.js` through openvibe-publishing/search-feed): sent a minute after boot, hourly, after each catalog pull and shortly after a staff change; only changed pages are sent, and a hidden or gone entry becomes a tombstone.
- **IndexNow.** With `INDEXNOW_KEY`, a catalog pull that adds a plan or a provider, or changes a plan's price, sends those pages and `/sitemap.xml` to search engines in one batch (a re-check at the same price sends nothing); a new offer post sends `/catalog/offers`. The key is served at `/<key>.txt`.
--- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers` and `/catalog/domains` (with each ending's page and the name check). They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
++- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.
## Static hosting moved
@@@ -75,7 +76,7 @@ Stage A adds a few safety rules of its own:
## Owns
- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, scheduled backups (pgBackRest verification and logical PostgreSQL dumps) with retention and encrypted off-host copies, restore drills.
--- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), offers (0009), registrars' domain prices and their history (0011), and the catalog's Search documents (`host.index_document.*`).
++- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), offers (0009), registrars' domain prices and their history (0011), benchmarks and their one-time links (0012), and the catalog's Search documents (`host.index_document.*`).
- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.
- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user. `ovhost data provision <svc>` runs `add-service.sh` from the installed CLI, redacting every URL and password: [docs/data-provisioning.md](docs/data-provisioning.md).
ddiff --git a/STATUS.json b/STATUS.json
iindex dd1666a..27acdee 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -48,7 +48,8 @@
"browser check and release-lifecycle acceptance suite",
"catalog provider and plan discussion threads on OpenVibe.Community (optional HOST_COMMUNITY_URL; server-rendered comments, after-commit visibility sync)",
"hosting comparison catalog with providers, plans, promotions, price history and discussion",
-- "domain prices compared (first year, renewal, transfer, five years per TLD from registrars' public lists through Sources) and a name check over RDAP at /catalog/domains"
++ "domain prices compared (first year, renewal, transfer, five years per TLD from registrars' public lists through Sources) and a name check over RDAP at /catalog/domains",
++ "user-submitted server benchmarks (YABS posts to a one-time link: Geekbench, fio, iperf3) with medians on plan and provider pages, at /catalog/benchmarks"
],
"retired": {
"movedTo": "https://openvibe.space",
ddiff --git a/migrations/0012_catalog_benchmarks.sql b/migrations/0012_catalog_benchmarks.sql
nnew file mode 100644
iindex 0000000..652db03
---- /dev/null
++++ b/migrations/0012_catalog_benchmarks.sql
@@@ -0,0 +1,58 @@
++-- phase: expand
++-- Benchmarks (plan T20, the owner's "benchmarks and user submittable community curated content"; Q21: user-submitted):
++-- a signed-in person asks for a one-time link, runs YABS (Yet Another Bench Script) on their server with it, and YABS
++-- posts its JSON result here (server/catalog/benchmarks.js). Host keeps the measurements (CPU, Geekbench, fio, iperf3),
++-- the provider and plan they named, and the city and network YABS looked up; never the server's address.
++CREATE TABLE catalog_bench_links (
++ token_hash text COLLATE "C" PRIMARY KEY,
++ person text COLLATE "C" NOT NULL,
++ provider_id text COLLATE "C" REFERENCES catalog_providers(id),
++ provider_name text NOT NULL CHECK (length(provider_name) BETWEEN 1 AND 80),
++ plan_id text COLLATE "C" REFERENCES catalog_plans(id),
++ note text CHECK (note IS NULL OR length(note) <= 200),
++ created_at bigint NOT NULL,
++ expires_at bigint NOT NULL,
++ used_at bigint
++);
++CREATE INDEX catalog_bench_links_person ON catalog_bench_links (person, created_at);
++CREATE TABLE catalog_benchmarks (
++ id text COLLATE "C" PRIMARY KEY,
++ provider_id text COLLATE "C" REFERENCES catalog_providers(id),
++ provider_name text NOT NULL CHECK (length(provider_name) BETWEEN 1 AND 80),
++ plan_id text COLLATE "C" REFERENCES catalog_plans(id),
++ note text CHECK (note IS NULL OR length(note) <= 200),
++ submitted_by text COLLATE "C",
++ tool text COLLATE "C" NOT NULL CHECK (tool IN ('yabs')),
++ tool_version text,
++ cpu_model text,
++ cpu_cores integer CHECK (cpu_cores >= 0),
++ cpu_freq text,
++ aes boolean,
++ virt text,
++ arch text,
++ distro text,
++ ram_mb numeric CHECK (ram_mb >= 0),
++ swap_mb numeric CHECK (swap_mb >= 0),
++ disk_gb numeric CHECK (disk_gb >= 0),
++ city text,
++ region text,
++ country text,
++ network text,
++ gb_version integer,
++ gb_single integer CHECK (gb_single >= 0),
++ gb_multi integer CHECK (gb_multi >= 0),
++ gb_url text,
++ disk_4k_iops numeric CHECK (disk_4k_iops >= 0),
++ disk_4k_mbps numeric CHECK (disk_4k_mbps >= 0),
++ disk_1m_mbps numeric CHECK (disk_1m_mbps >= 0),
++ fio jsonb,
++ iperf jsonb,
++ elapsed_s integer,
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','hidden')),
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX catalog_benchmarks_recent ON catalog_benchmarks (status, created_at DESC, id);
++CREATE INDEX catalog_benchmarks_provider ON catalog_benchmarks (provider_id, status, created_at DESC);
++CREATE INDEX catalog_benchmarks_plan ON catalog_benchmarks (plan_id, status, created_at DESC);
++CREATE INDEX catalog_benchmarks_person ON catalog_benchmarks (submitted_by);
ddiff --git a/public/css/catalog.css b/public/css/catalog.css
iindex 8f7bd2f..7959444 100644
---- a/public/css/catalog.css
++++ b/public/css/catalog.css
@@@ -190,3 +190,24 @@
/* The list of endings stays short on a phone: the first year, the renewal and five years; the rest is on each ending's page. */
.hd-list td[data-label="Transfer"], .hd-list td[data-label="Registrars"], .hd-list td[data-label="Checked"] { display: none; }
}
++
++/* Benchmarks (server/render/benchmarks.js). */
++.hb-scores { display: grid; grid-template-columns: minmax(14rem, 20rem) 1fr; gap: 1.25rem; align-items: start; margin-bottom: 1rem; }
++.hb-scores h2 { margin-top: 0; font-size: 1rem; }
++.hb-scores .hc-big { margin: 0; }
++.hb-label { margin: 0 0 .3rem; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--hs-muted); }
++.hb-command { margin: 0 0 1rem; padding: 1rem; border-radius: 12px; border: 1px solid var(--hs-line); background: var(--hs-bg); overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; user-select: all; }
++.hb-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: .75rem; padding: 1rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
++.hb-form label { display: flex; flex-direction: column; gap: .3rem; font-size: 13px; color: var(--hs-muted); }
++.hb-form label span { font-weight: 600; }
++.hb-form input, .hb-form select { width: 100%; box-sizing: border-box; min-height: 2.4rem; color: var(--hs-fg); background: var(--hs-bg); }
++.hb-form button { min-height: 2.4rem; font-weight: 700; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
++.hb-error { padding: .7rem .9rem; border-radius: 8px; border-left: 3px solid var(--hs-bad); background: var(--hs-card); }
++.hb-summary { margin: 1.5rem 0; padding: 1rem 1.2rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
++.hb-summary h2 { margin: 0 0 .4rem; font-size: 1.05rem; }
++.hb-summary p { margin: .35rem 0; }
++@media (max-width: 44rem) {
++ .hb-scores { grid-template-columns: 1fr; }
++ .hb-table td.hb-gb { grid-column: 2; order: -1; text-align: right; align-self: start; }
++ .hb-table td.hb-gb::before { display: none; }
++}
ddiff --git a/server/app.js b/server/app.js
iindex f0d7e83..974d381 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -27,6 +27,7 @@ const { createCatalogIngest } = require('./catalog/ingest');
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');
@@@ -77,10 +78,11 @@ async function createApp(opts = {}) {
const catalog = createCatalog({ store, community, log, indexnow, baseUrl: config.baseUrl });
const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });
++ const benchmarks = createBenchmarks({ store, baseUrl: config.baseUrl });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
-- const ctx = { config, store, outbox, catalog, domainCheck, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
++ const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
const app = express();
app.disable('x-powered-by');
app.disable('etag');
@@@ -122,13 +124,13 @@ 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: /', '', `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$', 'Disallow: /', '', `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()]);
const day = (ms) => Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString().slice(0, 10) : undefined;
const latest = (rows) => day(rows.reduce((m, p) => Math.max(m, Number(p.checked_at) || 0), 0) || undefined);
-- const urls = [{ path: '/', lastmod: latest(plans) }, ...legalPaths.map((p) => ({ path: p })), { path: '/catalog', lastmod: latest(plans) }, { path: '/catalog/promos' }, { path: '/catalog/providers', lastmod: latest(plans) },
++ const urls = [{ path: '/', lastmod: latest(plans) }, ...legalPaths.map((p) => ({ path: p })), { path: '/catalog', lastmod: latest(plans) }, { path: '/catalog/promos' }, { path: '/catalog/benchmarks' }, { path: '/catalog/providers', lastmod: latest(plans) },
...providers.map((p) => ({ path: `/catalog/providers/${p.slug}`, lastmod: latest(plans.filter((v) => v.provider_slug === p.slug)) })),
...plans.map((p) => ({ path: `/catalog/plans/${p.id}`, lastmod: day(p.checked_at) })),
...(tlds.length ? [{ path: '/catalog/domains', lastmod: latest(tlds) }, ...tlds.map((t) => ({ path: `/catalog/domains/${encodeURIComponent(t.tld)}`, lastmod: day(t.checked_at) }))] : [{ path: '/catalog/domains' }])];
@@@ -141,6 +143,7 @@ async function createApp(opts = {}) {
{ title: 'Compare hosting plans', url: '/catalog', text: 'Filter and compare hosting plans by price and resources.' },
{ title: 'Hosting providers', url: '/catalog/providers', text: 'Every provider in the catalog.' },
{ title: 'Hosting promotions', url: '/catalog/promos', text: 'Current provider promotions.' },
++ { title: 'Server benchmarks', url: '/catalog/benchmarks', text: 'CPU, disk and network benchmarks people ran on their own servers with YABS, by provider and plan.' },
{ title: 'Compare domain prices', url: '/catalog/domains', text: 'What registrars charge for each domain ending: first year, renewal, transfer and five years, and a name check at the registry.' },
{ title: 'What shipped on OpenVibe.Host', url: '/updates', text: 'The public update log.' },
...legalPaths.map((p) => ({ title: p.slice(1), url: p, text: `The ${p.slice(1)} of OpenVibe.Host.` })),
ddiff --git a/server/catalog/account-data.js b/server/catalog/account-data.js
iindex c992a96..fdad5ac 100644
---- a/server/catalog/account-data.js
++++ b/server/catalog/account-data.js
@@@ -1,20 +1,35 @@
'use strict';
--/** ADR-033: catalog rows are shared. Export authored rows; erase only the attribution. */
++/**
++ * ADR-033: catalog rows are shared. Export authored rows; erase only the attribution. A person's benchmark results and
++ * links (migration 0012) are exported; erasing deletes the links and detaches the results, which stay as anonymous
++ * measurements. Tables a held migration has not created yet are skipped.
++ */
const TABLES = Object.freeze(['catalog_providers', 'catalog_plans', 'catalog_promos']);
++const exists = async (db, table) => Boolean((await db.prepare('SELECT to_regclass(?) AS t').get(table)).t);
++
async function exportFor(db, personId) {
const out = {};
for (const table of TABLES) out[table] = await db.prepare(`SELECT * FROM ${table} WHERE created_by = ? ORDER BY created_at, id`).all(personId);
++ if (await exists(db, 'catalog_benchmarks')) {
++ out.catalog_benchmarks = await db.prepare('SELECT * FROM catalog_benchmarks WHERE submitted_by = ? ORDER BY created_at, id').all(personId);
++ out.catalog_bench_links = await db.prepare('SELECT provider_name, plan_id, note, created_at, expires_at, used_at FROM catalog_bench_links WHERE person = ? ORDER BY created_at').all(personId);
++ }
return out;
}
async function eraseFor(db, personId) {
const counts = {};
++ const benchmarks = await exists(db, 'catalog_benchmarks');
await db.tx(async (t) => {
for (const table of TABLES) {
const rows = await t.prepare(`SELECT id FROM ${table} WHERE created_by = ?`).all(personId);
await t.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by = ?`).run(personId);
counts[table] = rows.length;
}
++ if (benchmarks) {
++ counts.catalog_benchmarks = (await t.prepare('UPDATE catalog_benchmarks SET submitted_by = NULL WHERE submitted_by = ?').run(personId)).changes;
++ counts.catalog_bench_links = (await t.prepare('DELETE FROM catalog_bench_links WHERE person = ?').run(personId)).changes;
++ }
});
return counts;
}
ddiff --git a/server/catalog/benchmarks.js b/server/catalog/benchmarks.js
nnew file mode 100644
iindex 0000000..7946002
---- /dev/null
++++ b/server/catalog/benchmarks.js
@@@ -0,0 +1,209 @@
++'use strict';
++/**
++ * Benchmarks people run on their own servers (migrations/0012_catalog_benchmarks.sql). A signed-in person asks for a
++ * one-time link naming the provider (and plan) they are testing; YABS (Yet Another Bench Script, `-s <url>`) posts its
++ * JSON result to that link; Host keeps the measurements and shows them per provider and plan.
++ *
++ * parseYabs(body) YABS's JSON → the stored fields, or throws ApiError 400
++ * createBenchmarks({ store, baseUrl }) → { createLink, submit, list, get, summary, remove, ready }
++ *
++ * A link works once, for 24 hours; a person gets 10 a day. The server's address is never stored: YABS's own lookup gives
++ * the city and network, which are kept. Until 0012 has applied (a held contract migration holds it), reads answer empty
++ * and `ready()` is false.
++ */
++const crypto = require('crypto');
++const { newId } = require('../ids');
++const { ApiError } = require('../http/errors');
++
++const LINK_TTL_MS = 24 * 3600_000;
++const LINKS_PER_DAY = 10;
++const PAGE = 50;
++const YABS_COMMAND = (url) => `curl -sL https://yabs.sh | bash -s -- -s "${url}"`;
++
++const bad = (message) => { throw new ApiError(400, 'host.benchmark.invalid', message); };
++const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
++const missingTable = (err) => err && err.code === '42P01';
++const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
++const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max) : null);
++const numberIn = (v, max) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= max ? v : null);
++const int = (v, max) => { const n = numberIn(v, max); return n == null ? null : Math.round(n); };
++const round = (v, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
++// fio reports KB/s; pages show MB/s.
++const mbps = (v) => { const n = numberIn(v, 1e9); return n == null ? null : round(n / 1000); };
++
++/** YABS's JSON (yabs.sh builds it by hand, so every field is checked): CPU, memory, disk, fio, iperf3, Geekbench. */
++function parseYabs(body) {
++ if (!body || typeof body !== 'object' || Array.isArray(body)) bad('This is not a YABS result.');
++ const version = text(body.version, 40);
++ if (!version || !/^v\d{4}-\d{2}-\d{2}/.test(version)) bad('This is not a YABS result (no YABS version).');
++ const cpu = body.cpu && typeof body.cpu === 'object' ? body.cpu : {};
++ const os = body.os && typeof body.os === 'object' ? body.os : {};
++ const mem = body.mem && typeof body.mem === 'object' ? body.mem : {};
++ const ip = body.ip_info && typeof body.ip_info === 'object' ? body.ip_info : {};
++ const fio = (Array.isArray(body.fio) ? body.fio : []).slice(0, 8).filter((f) => f && typeof f === 'object' && /^\d+[km]$/.test(String(f.bs || ''))).map((f) => ({
++ bs: String(f.bs),
++ read_mbps: mbps(f.speed_r), write_mbps: mbps(f.speed_w), total_mbps: mbps(f.speed_rw),
++ read_iops: int(f.iops_r, 1e9), write_iops: int(f.iops_w, 1e9), total_iops: int(f.iops_rw, 1e9),
++ }));
++ const iperf = (Array.isArray(body.iperf) ? body.iperf : []).slice(0, 24).filter((x) => x && typeof x === 'object').map((x) => ({
++ mode: text(x.mode, 10), provider: text(x.provider, 60), location: text(x.loc, 60), send: text(x.send, 30), recv: text(x.recv, 30), latency: text(x.latency, 30),
++ })).filter((x) => x.provider || x.location);
++ const gb = (Array.isArray(body.geekbench) ? body.geekbench : []).filter((g) => g && typeof g === 'object' && int(g.version, 99) != null && (int(g.single, 1e7) != null || int(g.multi, 1e8) != null))
++ .sort((a, b) => b.version - a.version)[0] || null;
++ if (!fio.length && !iperf.length && !gb) bad('The result has no disk, network or Geekbench test in it.');
++ const at = (bs) => fio.find((f) => f.bs === bs);
++ const kib = (v) => numberIn(v, 1e13);
++ const gbUrl = gb && typeof gb.url === 'string' && /^https:\/\/browser\.geekbench\.com\/v\d+\/cpu\/\d+$/.test(gb.url.trim()) ? gb.url.trim() : null;
++ return {
++ tool: 'yabs', tool_version: version,
++ cpu_model: text(cpu.model, 200), cpu_cores: int(cpu.cores, 4096), cpu_freq: text(cpu.freq, 40), aes: typeof cpu.aes === 'boolean' ? cpu.aes : null,
++ virt: text(os.vm, 40), arch: text(os.arch, 20), distro: text(os.distro, 100),
++ ram_mb: kib(mem.ram) == null ? null : round(kib(mem.ram) / 1024, 0), swap_mb: kib(mem.swap) == null ? null : round(kib(mem.swap) / 1024, 0),
++ disk_gb: kib(mem.disk) == null ? null : round(kib(mem.disk) / 1048576, 1),
++ city: text(ip.city, 80), region: text(ip.region, 80), country: text(ip.country, 80), network: text(ip.org, 120) || text(ip.isp, 120),
++ gb_version: gb ? int(gb.version, 99) : null, gb_single: gb ? int(gb.single, 1e7) : null, gb_multi: gb ? int(gb.multi, 1e8) : null, gb_url: gbUrl,
++ disk_4k_iops: at('4k') ? at('4k').total_iops : null, disk_4k_mbps: at('4k') ? at('4k').total_mbps : null, disk_1m_mbps: at('1m') ? at('1m').total_mbps : null,
++ fio: fio.length ? fio : null, iperf: iperf.length ? iperf : null,
++ elapsed_s: body.runtime && typeof body.runtime === 'object' ? int(body.runtime.elapsed, 86400 * 7) : null,
++ };
++}
++
++const SORTS = {
++ new: 'b.created_at DESC, b.id DESC',
++ gb_single: 'b.gb_single DESC NULLS LAST, b.created_at DESC, b.id DESC',
++ gb_multi: 'b.gb_multi DESC NULLS LAST, b.created_at DESC, b.id DESC',
++ disk: 'b.disk_4k_iops DESC NULLS LAST, b.created_at DESC, b.id DESC',
++};
++
++function createBenchmarks({ store, baseUrl }) {
++ const db = store.db;
++ let isReady = null;
++ async function ready() {
++ if (isReady) return true;
++ const r = await db.prepare("SELECT to_regclass('catalog_benchmarks') AS t").get();
++ isReady = Boolean(r && r.t);
++ return isReady;
++ }
++ async function safe(fn, fallback) {
++ try { return await fn(); } catch (err) { if (missingTable(err)) { isReady = false; return fallback; } throw err; }
++ }
++
++ /** A one-time submit link for `person`, naming a catalog provider (by slug) or another provider by name, and a plan. */
++ async function createLink(person, input = {}) {
++ if (!person) throw new ApiError(401, 'host.benchmark.sign_in', 'Sign in to submit a benchmark');
++ if (!(await ready())) throw new ApiError(503, 'host.benchmark.unavailable', 'Benchmarks open after the next maintenance');
++ const now = store.now();
++ const recent = await db.prepare('SELECT COUNT(*) AS n FROM catalog_bench_links WHERE person = ? AND created_at > ?').get(person, now - 86400_000);
++ if (Number(recent.n) >= LINKS_PER_DAY) throw new ApiError(429, 'host.benchmark.too_many', `At most ${LINKS_PER_DAY} benchmark links a day`);
++ let provider = null;
++ let plan = null;
++ if (input.plan_id) {
++ plan = await db.prepare("SELECT p.id, p.provider_id, v.name FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.id = ? AND p.status = 'active' AND v.status = 'active'").get(String(input.plan_id));
++ if (!plan) invalid('That plan is not in the catalog');
++ provider = { id: plan.provider_id, name: plan.name };
++ } else if (input.provider) {
++ const row = await db.prepare("SELECT id, name FROM catalog_providers WHERE slug = ? AND status = 'active'").get(String(input.provider));
++ if (!row) invalid('That provider is not in the catalog');
++ provider = row;
++ }
++ const name = provider ? provider.name : text(input.provider_name, 80);
++ if (!name) invalid('Name the provider you are testing');
++ const note = text(input.note, 200);
++ const token = crypto.randomBytes(24).toString('base64url');
++ await db.prepare('INSERT INTO catalog_bench_links (token_hash, person, provider_id, provider_name, plan_id, note, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(sha(token), person, provider ? provider.id : null, name, plan ? plan.id : null, note, now, now + LINK_TTL_MS);
++ const url = `${baseUrl}/api/v1/catalog/benchmarks/yabs/${token}`;
++ return { url, command: YABS_COMMAND(url), provider_name: name, plan_id: plan ? plan.id : null, expires_at: new Date(now + LINK_TTL_MS).toISOString() };
++ }
++
++ // A link YABS can still post to, or the reason it cannot.
++ function usable(link, now) {
++ if (!link) throw new ApiError(404, 'host.benchmark.link_unknown', 'This benchmark link is not known');
++ if (link.used_at != null) throw new ApiError(410, 'host.benchmark.link_used', 'This link was already used: ask for a new one at openvibe.host/catalog/benchmarks/new');
++ if (Number(link.expires_at) <= now) throw new ApiError(410, 'host.benchmark.link_expired', 'This link has expired: ask for a new one at openvibe.host/catalog/benchmarks/new');
++ return link;
++ }
++ const tokenOk = (token) => typeof token === 'string' && /^[A-Za-z0-9_-]{32}$/.test(token);
++
++ /** Before reading a posted body: the link exists, is unused and unexpired. */
++ async function checkLink(token) {
++ if (!tokenOk(token)) usable(null);
++ const link = await safe(() => db.prepare('SELECT used_at, expires_at FROM catalog_bench_links WHERE token_hash = ?').get(sha(token)), null);
++ usable(link, store.now());
++ }
++
++ /** YABS posts here: the link is spent and the result stored, or nothing happens. Returns the new result's page. */
++ async function submit(token, body) {
++ if (!tokenOk(token)) usable(null);
++ const v = parseYabs(body);
++ const now = store.now();
++ const id = newId('catalogBenchmark', now);
++ await db.tx(async (t) => {
++ const link = usable(await t.prepare('SELECT * FROM catalog_bench_links WHERE token_hash = ? FOR UPDATE').get(sha(token)), now);
++ await t.prepare('UPDATE catalog_bench_links SET used_at = ? WHERE token_hash = ?').run(now, link.token_hash);
++ const row = {
++ id, provider_id: link.provider_id, provider_name: link.provider_name, plan_id: link.plan_id, note: link.note, submitted_by: link.person,
++ ...v, fio: v.fio ? JSON.stringify(v.fio) : null, iperf: v.iperf ? JSON.stringify(v.iperf) : null, status: 'active', created_at: now, updated_at: now,
++ };
++ await t.prepare(`INSERT INTO catalog_benchmarks (${Object.keys(row).join(', ')}) VALUES (${Object.keys(row).map((k) => (k === 'fio' || k === 'iperf' ? '?::jsonb' : '?')).join(', ')})`).run(...Object.values(row));
++ });
++ return { id, url: `${baseUrl}/catalog/benchmarks/${id}` };
++ }
++
++ const out = (b) => ({
++ ...b, ram_mb: b.ram_mb == null ? null : Number(b.ram_mb), swap_mb: b.swap_mb == null ? null : Number(b.swap_mb), disk_gb: b.disk_gb == null ? null : Number(b.disk_gb),
++ disk_4k_iops: b.disk_4k_iops == null ? null : Number(b.disk_4k_iops), disk_4k_mbps: b.disk_4k_mbps == null ? null : Number(b.disk_4k_mbps), disk_1m_mbps: b.disk_1m_mbps == null ? null : Number(b.disk_1m_mbps),
++ fio: typeof b.fio === 'string' ? JSON.parse(b.fio) : b.fio, iperf: typeof b.iperf === 'string' ? JSON.parse(b.iperf) : b.iperf,
++ created_at: new Date(Number(b.created_at)).toISOString(), updated_at: undefined, submitted_by: undefined,
++ });
++ const SELECT = `SELECT b.*, v.slug AS provider_slug, p.name AS plan_name FROM catalog_benchmarks b
++ LEFT JOIN catalog_providers v ON v.id = b.provider_id AND v.status = 'active' LEFT JOIN catalog_plans p ON p.id = b.plan_id AND p.status = 'active'`;
++
++ async function list(query = {}) {
++ const allowed = new Set(['sort', 'provider', 'plan', 'cursor']);
++ for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);
++ const sort = query.sort || 'new';
++ if (!SORTS[sort]) invalid('Invalid sort');
++ const offset = query.cursor == null || query.cursor === '' ? 0 : Number(query.cursor);
++ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10000) invalid('Invalid cursor');
++ let where = "b.status = 'active'";
++ const args = [];
++ if (query.provider) { where += ' AND (v.slug = ? OR lower(b.provider_name) = lower(?))'; args.push(String(query.provider), String(query.provider)); }
++ if (query.plan) { where += ' AND b.plan_id = ?'; args.push(String(query.plan)); }
++ return await safe(async () => {
++ const rows = await db.prepare(`${SELECT} WHERE ${where} ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);
++ return { benchmarks: rows.slice(0, PAGE).map(out), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };
++ }, { benchmarks: [], next_cursor: null });
++ }
++
++ async function get(id, viewer = null) {
++ const row = await safe(() => db.prepare(`${SELECT} WHERE b.id = ? AND b.status = 'active'`).get(String(id)), null);
++ if (!row) throw new ApiError(404, 'host.catalog.not_found', 'No such benchmark');
++ return { ...out(row), mine: Boolean(viewer && viewer.subject && viewer.subject === row.submitted_by) };
++ }
++
++ /** The medians for a provider or a plan (a provider page's and a plan page's line). */
++ async function summary({ providerId, planId }) {
++ const [col, val] = planId ? ['b.plan_id', planId] : ['b.provider_id', providerId];
++ return await safe(async () => {
++ const r = await db.prepare(`SELECT COUNT(*) AS n, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_single) AS gb_single, percentile_cont(0.5) WITHIN GROUP (ORDER BY b.gb_multi) AS gb_multi,
++ percentile_cont(0.5) WITHIN GROUP (ORDER BY b.disk_4k_iops) AS disk_4k_iops, MAX(b.gb_version) AS gb_version, MAX(b.created_at) AS latest
++ FROM catalog_benchmarks b WHERE ${col} = ? AND b.status = 'active'`).get(val);
++ const n = Number(r.n) || 0;
++ return n ? { count: n, gb_single: r.gb_single == null ? null : Math.round(Number(r.gb_single)), gb_multi: r.gb_multi == null ? null : Math.round(Number(r.gb_multi)),
++ disk_4k_iops: r.disk_4k_iops == null ? null : Math.round(Number(r.disk_4k_iops)), gb_version: r.gb_version == null ? null : Number(r.gb_version), latest: new Date(Number(r.latest)).toISOString() } : { count: 0 };
++ }, { count: 0 });
++ }
++
++ /** The person who submitted a result, or staff, takes it down. */
++ async function remove(id, viewer) {
++ const row = await db.prepare("SELECT submitted_by FROM catalog_benchmarks WHERE id = ? AND status = 'active'").get(String(id));
++ if (!row) throw new ApiError(404, 'host.catalog.not_found', 'No such benchmark');
++ if (!viewer || viewer.kind !== 'user' || !(viewer.staff || viewer.subject === row.submitted_by)) throw new ApiError(403, 'host.catalog.forbidden', 'Only the person who submitted it, or staff, can remove it');
++ await db.prepare("UPDATE catalog_benchmarks SET status = 'hidden', updated_at = ? WHERE id = ?").run(store.now(), String(id));
++ }
++
++ return { createLink, checkLink, submit, list, get, summary, remove, ready, SORTS: Object.keys(SORTS) };
++}
++
++module.exports = { parseYabs, createBenchmarks, YABS_COMMAND, LINK_TTL_MS, LINKS_PER_DAY };
ddiff --git a/server/catalog/domain-store.js b/server/catalog/domain-store.js
iindex d9e5e53..46e4df4 100644
---- a/server/catalog/domain-store.js
++++ b/server/catalog/domain-store.js
@@@ -39,7 +39,7 @@ function createDomainStore({ store, announce = () => {} }) {
async function ready() {
if (isReady) return true;
-- const r = await db.prepare("SELECT to_regclass('public.catalog_tld_prices') AS t").get();
++ const r = await db.prepare("SELECT to_regclass('catalog_tld_prices') AS t").get();
isReady = Boolean(r && r.t);
return isReady;
}
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 3339617..a527121 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -253,8 +253,10 @@ function createCatalog({ store, community = null, log = console, indexnow = null
const r = await db.prepare("SELECT COUNT(*) AS plans, COUNT(DISTINCT p.provider_id) AS providers, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active'").get();
return { plans: Number(r.plans) || 0, providers: Number(r.providers) || 0, lastChecked: r.last == null ? null : Number(r.last) };
}
++ /** Every public plan for a form's choice list: provider by name, then cheapest first. */
++ async function planChoices() { return await db.prepare("SELECT p.id, p.name, v.name AS provider_name, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY v.name, p.price_monthly, p.id LIMIT 1000").all(); }
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
-- return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
++ return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
}
module.exports = { createCatalog };
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex 05a8daa..184e4ef 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -6,6 +6,7 @@ const { ApiError, run, privateNoStore, sendError } = require('./errors');
const { renderPage, esc } = require('../render/layout');
const render = require('../render/catalog');
const renderDomains = require('../render/domains');
++const renderBench = require('../render/benchmarks');
const rateLimit = require('express-rate-limit');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { guard } = require('../auth/viewer');
@@@ -14,7 +15,7 @@ const cache = require('openvibe-shared/cache-policy');
const disclosure = render.DISCLOSURE;
// A name check asks a registry: 30 a minute per address, below every registry's own RDAP limit.
const checkLimit = () => rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });
--function createCatalogApi({ catalog, domainCheck, viewers, actorLimits }) {
++function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimits, log = console }) {
const router = express.Router();
router.use(viewers.middleware('api'));
if (actorLimits) router.use(actorLimits);
@@@ -30,6 +31,27 @@ function createCatalogApi({ catalog, domainCheck, viewers, actorLimits }) {
router.get('/domains', run(async (req) => await catalog.domains.list(req.query)));
router.get('/domains/check', checkLimit(), run(async (req) => ({ check: await domainCheck.check(req.query.name) })));
router.get('/domains/:tld', run(async (req) => ({ tld: await catalog.domains.tld(req.params.tld) })));
++ router.get('/benchmarks', run(async (req) => await benchmarks.list(req.query)));
++ router.get('/benchmarks/:id', run(async (req) => ({ benchmark: await benchmarks.get(req.params.id, req.viewer) })));
++ router.post('/benchmarks/links', catalogJsonBody, run(async (req) => {
++ if (req.viewer.kind !== 'user') throw new ApiError(401, 'host.benchmark.sign_in', 'Sign in to submit a benchmark');
++ return { link: await benchmarks.createLink(req.viewer.subject, req.body || {}) };
++ }, 201));
++ router.delete('/benchmarks/:id', run(async (req) => { await benchmarks.remove(req.params.id, req.viewer); return { removed: true }; }));
++ // YABS posts its JSON here (`-s <url>`) and prints the answer in the terminal, so the answer is one line of text.
++ router.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 {
++ await benchmarks.checkLink(req.params.token);
++ let body;
++ try { body = JSON.parse(typeof req.body === 'string' ? req.body : ''); } catch { throw new ApiError(400, 'host.benchmark.invalid', 'The result was not valid JSON; nothing was saved.'); }
++ const saved = await benchmarks.submit(req.params.token, body);
++ res.status(201).send(`\nOpenVibe.Host saved this result: ${saved.url}\n`);
++ } 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`);
++ }
++ });
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) })));
@@@ -37,7 +59,7 @@ function createCatalogApi({ catalog, domainCheck, viewers, actorLimits }) {
router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err); });
return router;
}
--function createCatalogPages({ catalog, domainCheck, community, config, viewers, actorLimits, log = console }) {
++function createCatalogPages({ catalog, domainCheck, benchmarks, community, config, viewers, actorLimits, log = console }) {
const router = express.Router();
router.use(viewers.middleware('dashboard'));
if (actorLimits) router.use(actorLimits);
@@@ -82,13 +104,15 @@ function createCatalogPages({ catalog, domainCheck, community, config, viewers,
}));
router.get('/providers/:slug', view(async (req, res) => {
const p = await catalog.providers(req.params.slug);
-- const discussion = await discussionFor(req, 'provider', p, p.name);
-- page(req, res, `${p.name} plans and prices`, render.provider(p, discussion), true, `${p.name}'s hosting plans compared: monthly prices, per-resource prices and the day each was checked.`);
++ 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 = await discussionFor(req, 'plan', p, `${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 }), 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));
++ 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.');
@@@ -124,6 +148,43 @@ function createCatalogPages({ catalog, domainCheck, community, config, viewers,
`${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.');
++ }));
++ 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));
++ cachePage(req, res, false);
++ res.status(status).type('html').send(renderPage({ title: 'Benchmark your server', body: renderBench.submitForm({ viewer: req.viewer, providers: withPlans, plans, csrf: csrfToken(config, req.viewer), error, query: req.method === 'POST' ? req.body : req.query }), viewer: req.viewer, config, path: req.originalUrl, indexable: false, css: ['css/catalog.css'] }));
++ };
++ router.get('/benchmarks/new', view(async (req, res) => benchForm(req, res)));
++ const benchParser = express.urlencoded({ extended: false, limit: '8kb' });
++ router.post('/benchmarks/new', benchParser, view(async (req, res) => {
++ if (req.viewer.kind !== 'user') return res.redirect(303, '/catalog/benchmarks/new');
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body.csrf)) throw new ApiError(403, 'host.catalog.forbidden', 'Invalid form token or origin');
++ let l;
++ try {
++ l = await benchmarks.createLink(req.viewer.subject, { provider: req.body.provider || undefined, provider_name: req.body.provider_name, plan_id: req.body.plan_id || undefined, note: req.body.note });
++ } catch (err) {
++ if (![422, 429, 503].includes(err.status)) throw err;
++ return benchForm(req, res, { error: err.message, status: err.status });
++ }
++ res.set('Cache-Control', 'private, no-store');
++ res.status(201).type('html').send(renderPage({ title: 'Your benchmark link', body: renderBench.issued(l), viewer: req.viewer, config, path: req.originalUrl, indexable: false, css: ['css/catalog.css'] }));
++ }));
++ router.get('/benchmarks/:id', view(async (req, res) => {
++ const b = await benchmarks.get(req.params.id, req.viewer);
++ const canRemove = req.viewer.kind === 'user' && (b.mine || req.viewer.staff);
++ page(req, res, `${b.provider_name}${b.plan_name ? ` ${b.plan_name}` : ''} benchmark`, renderBench.detail(b, { csrf: canRemove ? csrfToken(config, req.viewer) : '', canRemove }), false,
++ `A YABS benchmark of a ${b.provider_name} server: Geekbench, disk and network results.`);
++ }));
++ 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');
++ await benchmarks.remove(req.params.id, req.viewer);
++ res.redirect(303, '/catalog/benchmarks');
++ }));
router.get('/manage', view(async (req, res) => {
if (req.viewer.kind !== 'user' || !req.viewer.staff) throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');
const csrf = csrfToken(config, req.viewer);
ddiff --git a/server/ids.js b/server/ids.js
iindex 33999c0..85c3ec7 100644
---- a/server/ids.js
++++ b/server/ids.js
@@@ -2,7 +2,7 @@
/** Catalog identifiers: <prefix>_<ULID>. */
const { ids } = require('openvibe-contracts');
--const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr', catalogOffer: 'hof', catalogTldPrice: 'hdp' };
++const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr', catalogOffer: 'hof', catalogTldPrice: 'hdp', catalogBenchmark: 'hbm' };
const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
ddiff --git a/server/render/benchmarks.js b/server/render/benchmarks.js
nnew file mode 100644
iindex 0000000..3eeb9f1
---- /dev/null
++++ b/server/render/benchmarks.js
@@@ -0,0 +1,135 @@
++'use strict';
++/**
++ * Benchmarks people ran on their own servers (server/catalog/benchmarks.js). Server-rendered, complete without
++ * JavaScript:
++ *
++ * list({ result, query }) /catalog/benchmarks: newest first, or by Geekbench or disk speed
++ * detail(b, { csrf }) /catalog/benchmarks/:id: one result in full
++ * submitForm({ viewer, providers, plans, csrf, error }) /catalog/benchmarks/new
++ * issued(link) the command with its one-time link
++ * summaryBlock(s, { href, label }) a provider's or a plan's medians, on its page
++ */
++const { esc } = require('./layout');
++const { bytesMb } = require('./catalog');
++
++const SORTS = [['new', 'Newest'], ['gb_single', 'Geekbench single-core'], ['gb_multi', 'Geekbench multi-core'], ['disk', 'Disk (4k IOPS)']];
++const day = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : ''; };
++const link = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''}>${label}</a>`;
++const out = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''} rel="nofollow noopener">${label}</a>`;
++const n = (v, unit = '') => (v == null ? '<span class="hc-unknown">Not run</span>' : `${esc(Number(v).toLocaleString('en'))}${unit}`);
++const select = (name, label, options, current) => `<label><span>${esc(label)}</span><select name="${name}">${options.map(([v, l]) => `<option value="${esc(v)}"${String(current || '') === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
++const where = (b) => [b.city, b.country].filter(Boolean).join(', ');
++const providerLink = (b) => (b.provider_slug ? link(`/catalog/providers/${encodeURIComponent(b.provider_slug)}`, esc(b.provider_name)) : esc(b.provider_name));
++const planLink = (b) => (b.plan_name ? link(`/catalog/plans/${encodeURIComponent(b.plan_id)}`, esc(b.plan_name)) : '');
++
++function table(rows, caption) {
++ return `<div class="hc-table-wrap"><table class="hc-table hb-table"><caption>${esc(caption)}</caption>
++<thead><tr><th scope="col">Server</th><th scope="col">CPU</th><th scope="col" class="hc-n">Geekbench</th><th scope="col" class="hc-n">Disk 4k</th><th scope="col" class="hc-n">Disk 1m</th><th scope="col">Where</th><th scope="col">Run</th></tr></thead>
++<tbody>${rows.map((b) => `<tr>
++<th scope="row" data-label="Server">${link(`/catalog/benchmarks/${encodeURIComponent(b.id)}`, esc(b.provider_name))}<span class="hc-kind">${esc([b.plan_name, b.note].filter(Boolean).join(' · ') || (b.ram_mb ? `${bytesMb(b.ram_mb)} RAM` : ''))}</span></th>
++<td data-label="CPU">${esc(b.cpu_model || 'Unknown')}<span class="hc-kind">${b.cpu_cores != null ? `${esc(String(b.cpu_cores))} core${b.cpu_cores === 1 ? '' : 's'}` : ''}${b.virt ? ` · ${esc(b.virt)}` : ''}</span></td>
++<td data-label="Geekbench" class="hc-n hb-gb">${b.gb_single != null || b.gb_multi != null ? `<span class="hc-price">${n(b.gb_single)}</span> / ${n(b.gb_multi)}<span class="hc-kind">GB${esc(String(b.gb_version || ''))} single / multi</span>` : '<span class="hc-unknown">Not run</span>'}</td>
++<td data-label="Disk 4k" class="hc-n">${b.disk_4k_iops != null ? `${n(b.disk_4k_iops)}<span class="hc-kind">IOPS</span>` : '<span class="hc-unknown">Not run</span>'}</td>
++<td data-label="Disk 1m" class="hc-n">${b.disk_1m_mbps != null ? `${n(Math.round(b.disk_1m_mbps))}<span class="hc-kind">MB/s</span>` : '<span class="hc-unknown">Not run</span>'}</td>
++<td data-label="Where">${esc(where(b) || 'Unknown')}${b.network ? `<span class="hc-kind">${esc(b.network)}</span>` : ''}</td>
++<td data-label="Run" class="hc-checked">${esc(day(b.created_at))}</td>
++</tr>`).join('')}</tbody></table></div>`;
++}
++
++const HOW = `<section class="hc-method" aria-labelledby="hb-method-h"><h2 id="hb-method-h">How benchmarks get here</h2><ul>
++<li><strong>Run by people on their own servers.</strong> A signed-in member asks for a one-time link naming the provider (and plan) they are testing, and runs YABS (Yet Another Bench Script) with it. YABS posts its result here when it finishes.</li>
++<li><strong>What is kept.</strong> The CPU, memory and disk, Geekbench scores with their public link, fio disk speeds, iperf3 network speeds, and the city and network YABS looked up. Never the server's address, and never who ran it on the public page.</li>
++<li><strong>Read with care.</strong> One run is one server on one day: neighbours, location and the plan's limits all change the numbers. Medians across several runs say more.</li>
++<li><strong>Curated.</strong> The person who submitted a result, or staff, can take it down.</li>
++</ul></section>`;
++
++function list({ result, query = {} }) {
++ const q = query || {};
++ const rows = result.benchmarks;
++ const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog/benchmarks?${new URLSearchParams({ ...q, cursor: result.next_cursor })}`, 'Next 50 results →', 'button hc-button-quiet')}</p>` : '';
++ const filtered = q.provider || q.plan;
++ return `<header class="hc-head hc-head-row"><div><p class="hc-eyebrow">OpenVibe.Host · benchmarks</p><h1>Server benchmarks</h1>
++<p class="hc-lede">CPU, disk and network results people ran on their own servers with YABS, by provider and plan.</p></div>
++<p>${link('/catalog/benchmarks/new', 'Submit a benchmark', 'button')}</p></header>
++${rows.length || filtered ? `<form class="hc-filters" method="get" action="/catalog/benchmarks" role="search" aria-label="Sort benchmarks">
++${select('sort', 'Sort by', SORTS, q.sort || 'new')}
++<label><span>Provider</span><input name="provider" value="${esc(q.provider || '')}" placeholder="Any" autocapitalize="off"></label>
++<div class="hc-filter-actions"><button type="submit">Show</button> <a href="/catalog/benchmarks" class="hc-reset">Reset</a></div>
++</form>` : ''}
++${rows.length ? table(rows, `${rows.length}${result.next_cursor ? '+' : ''} result${rows.length === 1 ? '' : 's'}`)
++ : filtered ? `<div class="hc-empty"><p><strong>No result for this provider yet.</strong></p><p>${link('/catalog/benchmarks/new', 'Run one on your server')} or ${link('/catalog/benchmarks', 'see every result')}.</p></div>`
++ : `<div class="hc-empty hc-empty-rich"><p><strong>Be the first to add a result.</strong></p><p>Have a VPS or a dedicated server? One command measures it, and the result lands here for everyone comparing providers.</p><p>${link('/catalog/benchmarks/new', 'Submit a benchmark', 'button')}</p></div>`}
++${next}
++${HOW}
++<p class="hc-links">${link('/catalog', '← Compare plans')} · ${link('/catalog/offers', 'Hosting offers')}</p>`;
++}
++
++function detail(b, { csrf = '', canRemove = false } = {}) {
++ const spec = [
++ ['CPU', b.cpu_model], ['Cores', b.cpu_cores], ['Clock', b.cpu_freq], ['AES-NI', b.aes == null ? null : b.aes ? 'Yes' : 'No'],
++ ['Virtualization', b.virt], ['RAM', b.ram_mb != null ? bytesMb(b.ram_mb) : null], ['Swap', b.swap_mb ? bytesMb(b.swap_mb) : null],
++ ['Disk', b.disk_gb != null ? `${b.disk_gb} GB` : null], ['Architecture', b.arch], ['System', b.distro],
++ ];
++ const fio = b.fio && b.fio.length ? `<div class="hc-table-wrap"><table class="hc-table hb-fio"><caption>fio, random reads and writes (50/50)</caption>
++<thead><tr><th scope="col">Block size</th><th scope="col" class="hc-n">Read</th><th scope="col" class="hc-n">Write</th><th scope="col" class="hc-n">Total</th><th scope="col" class="hc-n">IOPS</th></tr></thead>
++<tbody>${b.fio.map((f) => `<tr><th scope="row" data-label="Block size">${esc(f.bs)}</th><td data-label="Read" class="hc-n">${n(f.read_mbps, ' MB/s')}</td><td data-label="Write" class="hc-n">${n(f.write_mbps, ' MB/s')}</td><td data-label="Total" class="hc-n"><strong>${n(f.total_mbps, ' MB/s')}</strong></td><td data-label="IOPS" class="hc-n">${n(f.total_iops)}</td></tr>`).join('')}</tbody></table></div>` : '<p class="hc-dim">The disk test was not run.</p>';
++ const iperf = b.iperf && b.iperf.length ? `<div class="hc-table-wrap"><table class="hc-table hb-iperf"><caption>iperf3, to public test servers</caption>
++<thead><tr><th scope="col">Server</th><th scope="col">Where</th><th scope="col" class="hc-n">Send</th><th scope="col" class="hc-n">Receive</th><th scope="col" class="hc-n">Latency</th></tr></thead>
++<tbody>${b.iperf.map((x) => `<tr><th scope="row" data-label="Server">${esc(x.provider || '')}${x.mode ? ` <span class="hc-dim">${esc(x.mode)}</span>` : ''}</th><td data-label="Where">${esc(x.location || '')}</td><td data-label="Send" class="hc-n">${esc(x.send || '')}</td><td data-label="Receive" class="hc-n">${esc(x.recv || '')}</td><td data-label="Latency" class="hc-n">${esc(x.latency || '')}</td></tr>`).join('')}</tbody></table></div>` : '<p class="hc-dim">The network test was not run.</p>';
++ return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog/benchmarks', 'Benchmarks')} / ${esc(b.provider_name)}</nav>
++<header class="hc-head"><p class="hc-eyebrow">Benchmark · ${esc(day(b.created_at))}</p><h1>${esc(b.provider_name)}${b.plan_name ? ` ${esc(b.plan_name)}` : ''}</h1>
++<p class="hc-lede">${[b.provider_slug ? `${providerLink(b)}${b.plan_name ? ` · ${planLink(b)}` : ''}` : null, b.note ? esc(b.note) : null, where(b) ? esc(where(b)) : null, b.network ? esc(b.network) : null].filter(Boolean).join(' · ')}</p></header>
++<div class="hb-scores">
++<section class="hc-price-card" aria-label="Geekbench"><p class="hb-label">Geekbench ${esc(String(b.gb_version || ''))}</p>
++${b.gb_single != null || b.gb_multi != null ? `<p class="hc-big">${n(b.gb_single)}<span>single</span></p><p class="hc-big">${n(b.gb_multi)}<span>multi</span></p>${b.gb_url ? `<p>${out(b.gb_url, 'Full result on Geekbench Browser')}</p>` : ''}` : '<p class="hc-dim">Not run.</p>'}</section>
++<section aria-labelledby="hb-spec-h"><h2 id="hb-spec-h">The server</h2><dl class="hc-spec">${spec.filter(([, v]) => v != null && v !== '').map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(String(v))}</dd></div>`).join('')}</dl></section>
++</div>
++<h2>Disk</h2>
++${fio}
++<h2>Network</h2>
++${iperf}
++<p class="hc-dim">Run with YABS ${esc(b.tool_version || '')}${b.elapsed_s ? ` in ${esc(String(Math.round(b.elapsed_s / 60)))} minutes` : ''}, submitted ${esc(day(b.created_at))}.</p>
++${canRemove ? `<form method="post" action="/catalog/benchmarks/${esc(b.id)}/remove" class="inline"><input type="hidden" name="csrf" value="${esc(csrf)}"><button type="submit" class="hc-button-quiet">Remove this result</button></form>` : ''}
++<p class="hc-links">${link('/catalog/benchmarks', '← Every benchmark')}${b.provider_slug ? ` · ${link(`/catalog/benchmarks?provider=${encodeURIComponent(b.provider_slug)}`, `More ${esc(b.provider_name)} results`)}` : ''}</p>`;
++}
++
++function submitForm({ viewer, providers = [], plans = [], csrf = '', error = null, query = {} }) {
++ const head = `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog/benchmarks', 'Benchmarks')} / Submit</nav>
++<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · benchmarks</p><h1>Benchmark your server</h1>
++<p class="hc-lede">Name the provider and plan, get a one-time link, and run one command on the server. YABS measures the CPU (Geekbench), the disk (fio) and the network (iperf3) in about 10 to 15 minutes, then posts the result here.</p></header>`;
++ if (!viewer || viewer.kind !== 'user') {
++ return `${head}<div class="hc-empty hc-empty-rich"><p><strong>Sign in to submit a benchmark.</strong></p><p>Results are public; who submitted them is not. Signing in lets you remove your own results later.</p><p>${link('/auth/login?next=%2Fcatalog%2Fbenchmarks%2Fnew', 'Sign in', 'button')}</p></div>`;
++ }
++ const byProvider = new Map();
++ for (const p of plans) { if (!byProvider.has(p.provider_name)) byProvider.set(p.provider_name, []); byProvider.get(p.provider_name).push(p); }
++ return `${head}${error ? `<p class="hb-error" role="alert">${esc(error)}</p>` : ''}
++<form class="hb-form" method="post" action="/catalog/benchmarks/new">
++<input type="hidden" name="csrf" value="${esc(csrf)}">
++<label><span>Provider in the catalog</span><select name="provider"><option value="">Another provider (name it below)</option>${providers.map((p) => `<option value="${esc(p.slug)}"${query.provider === p.slug ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}</select></label>
++<label><span>Or the provider's name</span><input name="provider_name" maxlength="80" placeholder="e.g. RackNerd" value="${esc(query.provider_name || '')}"></label>
++<label><span>Plan (optional)</span><select name="plan_id"><option value="">Not in the catalog, or not sure</option>${[...byProvider].map(([name, list]) => `<optgroup label="${esc(name)}">${list.map((p) => `<option value="${esc(p.id)}"${query.plan_id === p.id ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}</optgroup>`).join('')}</select></label>
++<label><span>Note (optional)</span><input name="note" maxlength="200" placeholder="e.g. Black Friday 2 GB KVM, Los Angeles"></label>
++<div><button type="submit">Get my link</button></div>
++</form>
++<p class="hc-dim">YABS is open source: ${out('https://github.com/masonr/yet-another-bench-script', 'github.com/masonr/yet-another-bench-script')}. It downloads fio, iperf3 and Geekbench for the run and removes them after; Geekbench publishes its own result on browser.geekbench.com.</p>`;
++}
++
++function issued(l) {
++ return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog/benchmarks', 'Benchmarks')} / Submit</nav>
++<header class="hc-head"><p class="hc-eyebrow">Your link is ready</p><h1>Run this on your ${esc(l.provider_name)} server</h1>
++<p class="hc-lede">Copy the command into a shell on the server (as any user; no root needed for most tests). When YABS finishes it prints the address of your result here.</p></header>
++<pre class="hb-command"><code>${esc(l.command)}</code></pre>
++<ul class="hc-rules"><li>The link works once, until ${esc(l.expires_at.slice(0, 16).replace('T', ' '))} UTC. Ask for another for each server.</li>
++<li>Skip a test with YABS's own flags after <code>-s</code>: <code>-i</code> skips the network test, <code>-g</code> Geekbench, <code>-f</code> the disk test.</li>
++<li>Keep the link to yourself: whoever has it can post one result in your name.</li></ul>
++<p class="hc-links">${link('/catalog/benchmarks', '← Every benchmark')}</p>`;
++}
++
++function summaryBlock(s, { href, label }) {
++ if (!s || !s.count) return `<section class="hb-summary" aria-label="Benchmarks"><h2>Benchmarks</h2><p class="hc-dim">No one has posted a benchmark of ${esc(label)} yet. ${link('/catalog/benchmarks/new', 'Run one on your server')}.</p></section>`;
++ return `<section class="hb-summary" aria-label="Benchmarks"><h2>Benchmarks</h2>
++<p>${esc(String(s.count))} result${s.count === 1 ? '' : 's'} from people running ${esc(label)}. Medians: ${s.gb_single != null ? `Geekbench ${esc(String(s.gb_version || ''))} <strong>${esc(s.gb_single.toLocaleString('en'))}</strong> single, <strong>${esc((s.gb_multi || 0).toLocaleString('en'))}</strong> multi` : 'no Geekbench run'}${s.disk_4k_iops != null ? `; disk <strong>${esc(s.disk_4k_iops.toLocaleString('en'))}</strong> 4k IOPS` : ''}.</p>
++<p>${link(href, 'See every result →')} · ${link('/catalog/benchmarks/new', 'Add yours')}</p></section>`;
++}
++
++module.exports = { list, detail, submitForm, issued, summaryBlock, SORTS };
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex 9a83d86..738a6bf 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -182,7 +182,7 @@ ${d.comments.length ? `<ol class="hc-comments">${d.comments.map(one).join('')}</
<p>${d.thread.visibility === 'locked' ? 'This thread is locked.' : out(threadUrl, 'Join the discussion on OpenVibe.Community', { cls: 'button hc-button-quiet' })}</p></section>`;
}
--function provider(p, discussion = null) {
++function provider(p, discussion = null, extra = '') {
const cheapest = p.plans.length ? p.plans.reduce((m, x) => (Number(x.price_monthly) < Number(m.price_monthly) ? x : m), p.plans[0]) : null;
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav>
<header class="hc-head hc-head-row">
@@@ -191,13 +191,14 @@ function provider(p, discussion = null) {
${visit(p)}
</header>
${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false }) : p.domains ? `<p class="hc-more">${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `See ${esc(p.name)}'s domain prices →`, 'button')}</p>` : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}
++${extra}
${discussionSection(discussion)}
<h2>Promotions</h2>
${promoCards(p.promos, { showProvider: false })}
${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
}
--function plan(p, discussion = null, { baseUrl = '', watchUrl = '' } = {}) {
++function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' } = {}) {
const spec = [
['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
@@@ -224,6 +225,7 @@ ${visit(p)}
<h2>Price history</h2>
${history}
${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/plans/${p.id}`, value_path: 'plan.price_monthly', op: 'changed', name: `${p.provider_name} ${p.name} price` })}`, 'Tell me when this price changes', { cls: 'button hc-button-quiet' })} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}
++${extra}
${discussionSection(discussion)}
${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c0c7a41..89f407c 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const NETWORK_URL = 'https://openvibe.network';
--const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Domains', href: '/catalog/domains' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];
++const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Domains', href: '/catalog/domains' }, { label: 'Benchmarks', href: '/catalog/benchmarks' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];
const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';
const SITE_NAME = 'OpenVibe.Host';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
ddiff --git a/test/catalog-benchmarks.test.js b/test/catalog-benchmarks.test.js
nnew file mode 100644
iindex 0000000..24144d2
---- /dev/null
++++ b/test/catalog-benchmarks.test.js
@@@ -0,0 +1,159 @@
++'use strict';
++/**
++ * Benchmarks (migrations/0012, server/catalog/benchmarks.js): YABS's JSON is read field by field (KB/s → MB/s, KiB → MB,
++ * the newest Geekbench, only a Geekbench Browser link); a signed-in person gets a one-time link (24 hours, 10 a day)
++ * naming a catalog plan or another provider; YABS posts to it and gets one line of text back; the result shows on the
++ * list, its own page and the plan's and provider's medians; its submitter or staff removes it; account export carries
++ * it and erasure detaches it; the form works without JavaScript.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { parseYabs, LINKS_PER_DAY } = require('../server/catalog/benchmarks');
++const { csrfToken } = require('../server/auth/forms');
++const accountData = require('../server/catalog/account-data');
++
++const YABS = () => ({
++ version: 'v2026-10-07', time: '20261016-120000',
++ os: { arch: 'x64', distro: 'Ubuntu 24.04.1 LTS', kernel: '6.8.0-45-generic', uptime: 1234, vm: 'KVM' }, net: { ipv4: true, ipv6: false },
++ cpu: { model: 'AMD EPYC 7713 64-Core Processor', cores: 2, freq: '1999.999 MHz', aes: true, virt: true },
++ mem: { ram: 4012345, ram_units: 'KiB', swap: 1048572, swap_units: 'KiB', disk: 82536444, disk_units: 'KB' },
++ ip_info: { protocol: 'IPv4', isp: 'Akamai Connected Cloud', asn: 'AS63949 Akamai Technologies, Inc.', org: 'Linode', city: 'Newark', region: 'New Jersey', region_code: 'NJ', country: 'United States' },
++ partition: '/dev/sda',
++ fio: [
++ { bs: '4k', speed_r: 150000, iops_r: 37500, speed_w: 150300, iops_w: 37575, speed_rw: 300300, iops_rw: 75075, speed_units: 'KBps' },
++ { bs: '64k', speed_r: 600000, iops_r: 9375, speed_w: 603000, iops_w: 9421, speed_rw: 1203000, iops_rw: 18796, speed_units: 'KBps' },
++ { bs: '512k', speed_r: 800000, iops_r: 1562, speed_w: 840000, iops_w: 1640, speed_rw: 1640000, iops_rw: 3202, speed_units: 'KBps' },
++ { bs: '1m', speed_r: 900000, iops_r: 878, speed_w: 950000, iops_w: 927, speed_rw: 1850000, iops_rw: 1805, speed_units: 'KBps' },
++ ],
++ iperf: [{ mode: 'IPv4', provider: 'Clouvider', loc: 'London, UK (10G)', send: '1.20 Gbits/sec', recv: '1.05 Gbits/sec', latency: '75.3 ms' }],
++ geekbench: [{ version: 5, single: 1100, multi: 2050, url: 'https://browser.geekbench.com/v5/cpu/22222' }, { version: 6, single: 1350, multi: 2500, url: 'https://browser.geekbench.com/v6/cpu/1234567' }],
++ runtime: { start: 1, end: 2, elapsed: 780 },
++});
++const linode = (time = '2026-09-22T11:00:00Z') => ({ id: `itm_${'1'.repeat(26)}`, source_key: 'linode-types', category: 'hosting', kind: 'plan', identity: 'g6-standard-2', canonical_url: null, title: 'Linode 4 GB', summary: null, authors: [], published_at: null, source_updated_at: null,
++ fields: { vcpus: 2, memory: 4096, disk: 81920, transfer: 4000, price_monthly: 24, price_hourly: 0.036, class: 'standard' }, revision: 1, removed: null,
++ provenance: { retrieved_at: time, first_seen_at: time, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null } });
++
++(async () => {
++ await check('YABS JSON: the newest Geekbench, MB/s and MB, the city and network; not YABS or empty is refused', async () => {
++ const v = parseYabs(YABS());
++ assert.deepStrictEqual([v.tool_version, v.cpu_model, v.cpu_cores, v.aes, v.virt, v.arch], ['v2026-10-07', 'AMD EPYC 7713 64-Core Processor', 2, true, 'KVM', 'x64']);
++ assert.deepStrictEqual([v.ram_mb, v.swap_mb, v.disk_gb], [3918, 1024, 78.7]);
++ assert.deepStrictEqual([v.gb_version, v.gb_single, v.gb_multi, v.gb_url], [6, 1350, 2500, 'https://browser.geekbench.com/v6/cpu/1234567']);
++ assert.deepStrictEqual([v.disk_4k_iops, v.disk_4k_mbps, v.disk_1m_mbps], [75075, 300.3, 1850]);
++ assert.deepStrictEqual(v.fio[0], { bs: '4k', read_mbps: 150, write_mbps: 150.3, total_mbps: 300.3, read_iops: 37500, write_iops: 37575, total_iops: 75075 });
++ assert.deepStrictEqual([v.city, v.country, v.network, v.elapsed_s], ['Newark', 'United States', 'Linode', 780]);
++ assert.deepStrictEqual(v.iperf, [{ mode: 'IPv4', provider: 'Clouvider', location: 'London, UK (10G)', send: '1.20 Gbits/sec', recv: '1.05 Gbits/sec', latency: '75.3 ms' }]);
++ const odd = YABS(); odd.geekbench = [{ version: 6, single: 1, multi: 2, url: 'https://evil.test/v6/cpu/1' }]; odd.fio[0].speed_r = 'fast';
++ const o = parseYabs(odd);
++ assert.deepStrictEqual([o.gb_url, o.fio[0].read_mbps], [null, null], 'only a Geekbench Browser link; a value that is not a number is not invented');
++ assert.throws(() => parseYabs({ hello: 'world' }), /not a YABS result/);
++ assert.throws(() => parseYabs({ version: 'v2026-10-07', cpu: {} }), /no disk, network or Geekbench/);
++ assert.throws(() => parseYabs([1]), /not a YABS result/);
++ });
++
++ const t = await boot();
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++ const ORIGIN = { origin: 'https://openvibe.host' };
++ try {
++ await t.ctx.catalog.applyItem(linode());
++ const plan = (await t.api('GET', '/api/v1/catalog/plans')).json().plans[0];
++
++ await check('a one-time link: signed in only, names a catalog plan; YABS posts once and reads one line back', async () => {
++ assert.strictEqual((await t.api('POST', '/api/v1/catalog/benchmarks/links', { json: { plan_id: plan.id } })).status, 401);
++ const made = await t.api('POST', '/api/v1/catalog/benchmarks/links', { as: ada, json: { plan_id: plan.id, note: 'Newark' } });
++ assert.strictEqual(made.status, 201, made.text);
++ const link = made.json().link;
++ assert.match(link.url, /^https:\/\/openvibe\.host\/api\/v1\/catalog\/benchmarks\/yabs\/[A-Za-z0-9_-]{32}$/);
++ assert.strictEqual(link.command, `curl -sL https://yabs.sh | bash -s -- -s "${link.url}"`);
++ assert.strictEqual(link.provider_name, 'Linode');
++ const path = new URL(link.url).pathname;
++ const bad = await t.api('POST', path, { body: '{"version": "v2026', headers: { 'content-type': 'application/json' } });
++ assert.deepStrictEqual([bad.status, /not valid JSON/.test(bad.text)], [400, true], 'a broken body spends nothing');
++ const posted = await t.api('POST', path, { body: JSON.stringify(YABS()), headers: { 'content-type': 'application/json' } });
++ assert.strictEqual(posted.status, 201, posted.text);
++ assert.match(posted.headers['content-type'], /text\/plain/);
++ assert.match(posted.text, /^\nOpenVibe\.Host saved this result: https:\/\/openvibe\.host\/catalog\/benchmarks\/hbm_[0-9A-Z]{26}\n$/);
++ const again = await t.api('POST', path, { body: JSON.stringify(YABS()), headers: { 'content-type': 'application/json' } });
++ assert.deepStrictEqual([again.status, /already used/.test(again.text)], [410, true]);
++ assert.strictEqual((await t.api('POST', '/api/v1/catalog/benchmarks/yabs/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', { body: '{}' })).status, 404);
++ });
++
++ await check('the result: on the list, its own page, the plan\'s and the provider\'s medians; nobody\'s name', async () => {
++ const list = (await t.api('GET', '/api/v1/catalog/benchmarks')).json().benchmarks;
++ assert.strictEqual(list.length, 1);
++ assert.deepStrictEqual([list[0].provider_name, list[0].plan_name, list[0].note, list[0].gb_single, list[0].provider_slug], ['Linode', 'Linode 4 GB', 'Newark', 1350, 'linode']);
++ assert.ok(!('submitted_by' in list[0]) || list[0].submitted_by === undefined, 'the submitter is not public');
++ assert.doesNotMatch(JSON.stringify(list), new RegExp(ada.subject));
++ const page = await t.api('GET', '/catalog/benchmarks');
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<h1>Server benchmarks<\/h1>/);
++ assert.match(page.text, /AMD EPYC 7713/);
++ const one = await t.api('GET', `/catalog/benchmarks/${list[0].id}`);
++ assert.strictEqual(one.status, 200);
++ assert.match(one.text, /1,350<span>single<\/span>/);
++ assert.match(one.text, /Full result on Geekbench Browser/);
++ assert.match(one.text, /<strong>1,850 MB\/s<\/strong>/);
++ assert.match(one.text, /Clouvider/);
++ assert.match(one.text, /noindex/);
++ assert.doesNotMatch(one.text, /Remove this result/, 'a stranger gets no remove button');
++ const planPage = await t.api('GET', `/catalog/plans/${plan.id}`);
++ assert.match(planPage.text, /1 result from people running Linode Linode 4 GB\. Medians: Geekbench 6 <strong>1,350<\/strong> single/);
++ assert.match((await t.api('GET', '/catalog/providers/linode')).text, /4 GB|1 result from people running Linode/);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/benchmarks?provider=linode')).json().benchmarks.length, 1);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/benchmarks?sort=fastest')).status, 422);
++ });
++
++ await check('another provider by name; links expire after a day; ten a day each', async () => {
++ const other = (await t.api('POST', '/api/v1/catalog/benchmarks/links', { as: bo, json: { provider_name: 'RackNerd', note: 'Black Friday 2 GB' } })).json().link;
++ assert.strictEqual(other.provider_name, 'RackNerd');
++ const y = YABS(); delete y.geekbench; y.iperf = [];
++ assert.strictEqual((await t.api('POST', new URL(other.url).pathname, { body: JSON.stringify(y) })).status, 201);
++ const rn = (await t.api('GET', '/api/v1/catalog/benchmarks?provider=racknerd')).json().benchmarks;
++ assert.deepStrictEqual([rn.length, rn[0].provider_slug, rn[0].gb_single], [1, null, null]);
++ assert.strictEqual((await t.api('POST', '/api/v1/catalog/benchmarks/links', { as: bo, json: {} })).status, 422, 'a provider must be named');
++ const late = (await t.api('POST', '/api/v1/catalog/benchmarks/links', { as: bo, json: { provider: 'linode' } })).json().link;
++ t.clock.advance(25 * 3600_000);
++ const expired = await t.api('POST', new URL(late.url).pathname, { body: JSON.stringify(YABS()) });
++ assert.deepStrictEqual([expired.status, /expired/.test(expired.text)], [410, true]);
++ const made = [];
++ for (let i = 0; i < LINKS_PER_DAY; i++) made.push((await t.api('POST', '/api/v1/catalog/benchmarks/links', { as: bo, json: { provider: 'linode' } })).status);
++ assert.ok(made.every((s) => s === 201));
++ assert.strictEqual((await t.api('POST', '/api/v1/catalog/benchmarks/links', { as: bo, json: { provider: 'linode' } })).status, 429);
++ });
++
++ await check('the form works without JavaScript: sign in first, then a link and the command', async () => {
++ const anon = await t.api('GET', '/catalog/benchmarks/new');
++ assert.match(anon.text, /Sign in to submit a benchmark/);
++ const form = await t.api('GET', '/catalog/benchmarks/new', { session: ada });
++ assert.match(form.text, /<optgroup label="Linode">/);
++ assert.match(form.text, /name="csrf"/);
++ const csrf = csrfToken(t.config, { subject: ada.subject });
++ assert.strictEqual((await t.api('POST', '/catalog/benchmarks/new', { session: ada, headers: ORIGIN, form: { csrf: 'nope', provider: 'linode' } })).status, 403);
++ const issued = await t.api('POST', '/catalog/benchmarks/new', { session: ada, headers: ORIGIN, form: { csrf, provider: '', provider_name: 'Hetzner', plan_id: '', note: 'CX22' } });
++ assert.strictEqual(issued.status, 201, issued.text.slice(0, 300));
++ assert.match(issued.text, /Run this on your Hetzner server/);
++ assert.match(issued.text, /curl -sL https:\/\/yabs\.sh \| bash -s -- -s &quot;https:\/\/openvibe\.host\/api\/v1\/catalog\/benchmarks\/yabs\//);
++ assert.match(issued.headers['cache-control'], /no-store/);
++ const none = await t.api('POST', '/catalog/benchmarks/new', { session: ada, headers: ORIGIN, form: { csrf, provider: '', provider_name: '' } });
++ assert.deepStrictEqual([none.status, /Name the provider/.test(none.text)], [422, true]);
++ assert.match((await t.api('GET', '/robots.txt')).text, /Disallow: \/catalog\/benchmarks\/\nAllow: \/catalog\/benchmarks\$/);
++ });
++
++ await check('its submitter or staff removes it; export carries it and erasure detaches it', async () => {
++ const id = (await t.api('GET', '/api/v1/catalog/benchmarks?provider=linode')).json().benchmarks[0].id;
++ const own = await t.api('GET', `/catalog/benchmarks/${id}`, { session: ada });
++ assert.match(own.text, /Remove this result/);
++ assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: bo })).status, 403);
++ const exported = await accountData.exportFor(t.ctx.store.db, ada.subject);
++ assert.strictEqual(exported.catalog_benchmarks.length, 1);
++ assert.ok(exported.catalog_bench_links.length >= 2);
++ assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: ada })).status, 200);
++ assert.strictEqual((await t.api('GET', `/catalog/benchmarks/${id}`)).status, 404);
++ const erased = await accountData.eraseFor(t.ctx.store.db, bo.subject);
++ assert.deepStrictEqual([erased.catalog_benchmarks, erased.catalog_bench_links > 0], [1, true]);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/benchmarks?provider=racknerd')).json().benchmarks.length, 1, 'the measurement stays, anonymous');
++ });
++ } finally { await t.close(); }
++ done();
++})();
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 22f970e..d329661 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 250, // 239.1 (openvibe-shared 2.6.0 served scripts; was 212.2 on 2.2.0)
jsBrotliKB: 60, // 56.3 (was 49.9)
cssFiles: 3, // 3 (host.css, openvibe-shared showcase.css, catalog.css: the front page is the hosting catalog since Host#69)
-- cssRawKB: 31, // 29.9 (2026-10-16 the domain comparison's styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)
-- cssBrotliKB: 7, // 6.4 (5.7 before the domain styles, 3.6 before catalog.css)
++ cssRawKB: 33, // 31.9 (2026-10-16 the domain comparison's and benchmarks' styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)
++ cssBrotliKB: 7.5, // 6.9 (5.7 before the domain and benchmark styles, 3.6 before catalog.css)
externalFiles: 1, // 0
};