openvibers/openvibe.host Commits
Stage B launch readiness: staff takedowns, bounded uploads, no CA validation files, realip-only nginx headers, a launch that removes the pending vhost in the same change
Changes
@@@ -10,8 +10,9 @@ HOST=127.0.0.1NODE_ENV=development# The dashboard + API origin (production: https://openvibe.host)BASE_URL=http://localhost:4910--# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)--TRUST_PROXY=2++# Proxy hops in front of Node that set X-Forwarded-For. nginx resolves the Cloudflare hop itself++# (realip) and sends only $remote_addr, so there is one: nginx → Node = 1.++TRUST_PROXY=1# Tenant sites are served at <site>.<HOST_SITES_DOMAIN>. It must be its own registrable domain# (never under openvibe.network / openvibe.live). Development: localhost (http://<site>.localhost:4910)@@@ -43,6 +44,9 @@ HOST_FORM_SECRET=#HOST_MAX_UPLOAD_BYTES=104857600# Largest size an uploaded archive may unpack to (default 256 MiB; the storage quota also applies).#HOST_MAX_UNPACKED_BYTES=268435456++# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two++# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.++#HOST_MAX_CONCURRENT_UPLOADS=2# Per-project quota defaults (a staff override per project wins). Production projects:#HOST_QUOTA_STORAGE_BYTES=1073741824@@@ -1,6 +1,7 @@# *.openvibe.host before Host Stage B tenant hosting launches: a clear 404 over the wildcard# certificate instead of falling through to the default server (which served another site).--# Remove when `ovhost nginx tenants host --install` writes the real tenant vhost.++# `ovhost nginx tenants host --install` removes it in the same nginx -t and reload that installs the++# real tenant vhost (nginx.tenants.replaces); see docs/launch.md.server {listen 443 ssl;listen [::]:443 ssl;@@@ -1,5 +1,6 @@## REFERENCE copy of `ovhost nginx tenants host` output with no custom domains (regenerate it with--## that command; install with --install, which also writes the custom-domain vhost).++## that command; install with --install, which also writes the custom-domain vhost and removes++## openvibe.host-tenants-pending.conf in the same nginx -t and reload).## ═══════════════════════════════════════════════════════════════## openvibe.host and *.openvibe.host — generated by `ovhost nginx tenants host`##@@@ -79,6 +80,21 @@ server {}}++# ── www.openvibe.host → openvibe.host ─────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.host;++++ ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.host/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ add_header Strict-Transport-Security "max-age=31536000" always;++ return 301 https://openvibe.host$request_uri;++}++# ── Tenant sites ───────────────────────────────────────────server {listen 443 ssl;@@@ -4,7 +4,7 @@"owner": "host","status": "active","visibility": "first-party",-- "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes additionally need a Network staff user.",++ "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes and takedowns (stop serving a site or a whole project while keeping its content for review; lift) additionally need a Network staff user.","permissions": ["host:write"],@@@ -26,6 +26,10 @@"POST /api/v1/projects/:id/sites","GET /api/v1/sites/:id","DELETE /api/v1/sites/:id",-- "DELETE /api/v1/deploys/:id"++ "DELETE /api/v1/deploys/:id",++ "POST /api/v1/projects/:id/takedown",++ "DELETE /api/v1/projects/:id/takedown",++ "POST /api/v1/sites/:id/takedown",++ "DELETE /api/v1/sites/:id/takedown"]}@@@ -226,7 +226,7 @@"ready": { "url": "http://127.0.0.1:4910/api/ready", "timeoutSeconds": 30 },"noRestartPaths": ["docs/", "test/", "**/*.md"],"databases": [{ "name": "host", "path": "/var/lib/openvibe-host-api/host.db" }],-- "nginx": { "tenants": { "sitesDomain": "openvibe.host", "dashboardDomain": "openvibe.host", "wildcardCert": "openvibe.host", "database": "/var/lib/openvibe-host-api/host.db", "maxUpload": "110m" } },++ "nginx": { "tenants": { "sitesDomain": "openvibe.host", "dashboardDomain": "openvibe.host", "wildcardCert": "openvibe.host", "database": "/var/lib/openvibe-host-api/host.db", "maxUpload": "110m", "replaces": ["openvibe.host-tenants-pending.conf"] } },"drill": {"port": 14910,"databases": { "host": "HOST_DB_PATH" },@@@ -206,12 +206,13 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procconst domains = await lib.nginx.tenantDomains(exec, svc);const r = lib.nginx.renderTenants(inv, svc, { wildcardCert: opts['wildcard-cert'], domains });for (const h of r.refused) log(`refused a custom domain value that is not a plain host name: ${JSON.stringify(h)}`);-- if (json && !opts.install) { emit({ files: r.files.map((f) => f.name), certDir: r.certDir, needCert: r.needCert, refused: r.refused }); return 0; }++ if (json && !opts.install) { emit({ files: r.files.map((f) => f.name), replaces: r.replaces, certDir: r.certDir, needCert: r.needCert, refused: r.refused }); return 0; }if (!opts.install) {for (const f of r.files) out(`# ---- ${f.name} ----\n${f.text}`);++ if (r.replaces.length) out(`# --install also removes, in the same nginx -t and reload: ${r.replaces.join(', ')} (if present)`);} else {-- const res = await lib.nginx.install(exec, inv, r.files, { log });-- if (!res.changed.length) log('tenant vhosts already installed; nothing changed');++ const res = await lib.nginx.install(exec, inv, r.files, { log, remove: r.replaces });++ if (!res.changed.length && !res.removed.length) log('tenant vhosts already installed; nothing changed');}for (const h of r.needCert) log(`needs a certificate: certbot certonly --webroot -w /var/www/certbot -d ${h} (then run this again)`);return 0;@@@ -93,11 +93,33 @@ async function test(exec, inv) {/*** files: [{ name, text }]. Writes changed files to sites-available, links them into sites-enabled,* runs nginx -t; restores the previous state on failure; reloads nginx on success.-- * -> { changed: [names], reloaded }++ * remove: vhost file names to take out of sites-enabled and sites-available IN THE SAME CHANGE (an++ * interim vhost the new one replaces); they come back too if nginx -t fails.++ * -> { changed: [names], removed: [names], reloaded }*/--async function install(exec, inv, files, { log = () => {} } = {}) {++async function install(exec, inv, files, { log = () => {}, remove = [] } = {}) {const undo = [];const changed = [];++ const removed = [];++ for (const name of remove) {++ if (!/^[A-Za-z0-9._-]+$/.test(name) || files.some((f) => f.name === name)) throw new Error(`cannot remove vhost "${name}"`);++ const available = path.join(inv.nginx.sitesAvailable, name);++ const enabled = path.join(inv.nginx.sitesEnabled, name);++ const prevText = await exec.readFile(available, { privileged: true });++ // Usually a link to sites-available (possibly dangling); a plain copy is put back as a copy.++ const target = await exec.readlink(enabled);++ const link = target || await exec.stat(enabled);++ if (link) {++ const copy = target ? null : await exec.readFile(enabled, { privileged: true });++ await exec.removeFile(enabled, { privileged: true });++ undo.push(target ? () => exec.symlink(target, enabled, { privileged: true }) : () => exec.writeFile(enabled, copy == null ? '' : copy, { privileged: true, mode: 0o644 }));++ }++ if (prevText != null) {++ await exec.removeFile(available, { privileged: true });++ undo.push(() => exec.writeFile(available, prevText, { privileged: true, mode: 0o644 }));++ }++ if (link || prevText != null) removed.push(name);++ }for (const f of files) {if (!/^[A-Za-z0-9._-]+$/.test(f.name)) throw new Error(`vhost name "${f.name}" is not a file name`);const available = path.join(inv.nginx.sitesAvailable, f.name);@@@ -114,7 +136,7 @@ async function install(exec, inv, files, { log = () => {} } = {}) {if (!changed.includes(f.name)) changed.push(f.name);}}-- if (!changed.length) return { changed, reloaded: false };++ if (!changed.length && !removed.length) return { changed, removed, reloaded: false };const t = await test(exec, inv);if (!t.ok) {for (const u of undo.reverse()) await u();@@@ -123,8 +145,8 @@ async function install(exec, inv, files, { log = () => {} } = {}) {throw err;}await systemd.reloadNginx(exec);-- log(`nginx: installed ${changed.join(', ')}; nginx -t clean; reloaded`);-- return { changed, reloaded: true };++ log(`nginx: ${[changed.length && `installed ${changed.join(', ')}`, removed.length && `removed ${removed.join(', ')}`].filter(Boolean).join('; ')}; nginx -t clean; reloaded`);++ return { changed, removed, reloaded: true };}// ── Stage B: tenant static hosting (OpenVibe.Host's own service) ─────────────@@@ -159,6 +181,10 @@ function tenantsConfig(svc) {if (/(^|\.)openvibe\.(network|live|media|community|tools)$/.test(sitesDomain)) throw new Error(`tenant sites must not live under ${sitesDomain}: its cookies would reach tenant pages`);if (!svc.port) throw new Error(`${svc.id} has no port in the inventory`);if (t.maxUpload && !/^\d+[km]?$/.test(String(t.maxUpload))) throw new Error('nginx.tenants.maxUpload must look like 110m');++ // The interim vhost that answered *.<sitesDomain> with a 404 before launch (deploy/nginx in this++ // repo). It sorts before <sitesDomain>.conf, so left in place it would keep winning the wildcard.++ const replaces = t.replaces || [`${sitesDomain}-tenants-pending.conf`];++ if (!Array.isArray(replaces) || replaces.some((n) => typeof n !== 'string' || !/^[A-Za-z0-9._-]+\.conf$/.test(n))) throw new Error('nginx.tenants.replaces must list vhost file names');return {sitesDomain,dashboard,@@@ -168,6 +194,7 @@ function tenantsConfig(svc) {vhost: t.vhost || `${sitesDomain}.conf`,customVhost: t.customVhost || `${sitesDomain}-custom-domains.conf`,maxUpload: t.maxUpload || '110m',++ replaces,};}@@@ -181,8 +208,13 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {const zone = `ov${svc.id.replace(/[^a-z0-9]/g, '')}`;const base = { port: svc.port, zone, sitesDomain: t.sitesDomain, service: svc.id };const tenantLocation = fill(readTemplate('tenant-location.tmpl'), base);++ // www.<dashboard> would otherwise be a tenant name (reserved, so "unknown host"): redirect it,++ // as the placeholder did. Only when the wildcard certificate covers it.++ const www = `www.${t.dashboard}`;++ const wwwRedirect = www.endsWith(`.${t.sitesDomain}`) && www.split('.').length === t.sitesDomain.split('.').length + 1++ ? fill(readTemplate('www-redirect.tmpl'), { www, dashboard: t.dashboard, certDir }) : '';const main = fill(readTemplate('tenants.conf.tmpl'), {-- ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation,++ ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation, wwwRedirect,});const refused = [];@@@ -211,6 +243,7 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {needCert: withoutCert.map((d) => d.hostname),refused,certDir,++ replaces: t.replaces,};}@@@ -25,6 +25,7 @@ const { openStore } = require('./db');const { createBlobStore } = require('./storage');const { createHostOutbox } = require('./events/outbox');const { createAccess } = require('./domain/access');++const { createTakedowns } = require('./domain/takedowns');const { createProjects } = require('./domain/projects');const { createSites } = require('./domain/sites');const { createDeploys } = require('./domain/deploys');@@@ -32,6 +33,7 @@ const { createDomains } = require('./domain/domains');const { createAuthClient, createAuthRoutes } = require('./auth/sso');const { createViewerResolver } = require('./auth/viewer');const { createTenantServer } = require('./http/tenant');++const { createUploadGate } = require('./http/upload');const { createApi } = require('./http/api');const { createDashboard } = require('./http/dashboard');const { createHostReadiness } = require('./observability');@@@ -71,16 +73,18 @@ function createApp(opts = {}) {const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.fetchImpl, now: store.now, log });const access = createAccess({ store });-- const projects = createProjects({ store, config, access, blobs, log });-- const sites = createSites({ store, config, access, projects });-- const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, log });++ const takedowns = createTakedowns({ store });++ const projects = createProjects({ store, config, access, blobs, takedowns, log });++ const sites = createSites({ store, config, access, projects, takedowns });++ const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log });const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });const auth = opts.auth || createAuthClient(config);const viewers = createViewerResolver({ auth, config });-- const tenant = createTenantServer({ store, config, blobs, log });++ const tenant = createTenantServer({ store, config, blobs, takedowns, log });++ const uploadGate = createUploadGate(config.uploads.maxConcurrent);const worker = createWorker({ config, store, domains, blobs, outbox, log });-- const ctx = { config, store, blobs, outbox, access, projects, sites, deploys, domains, auth, viewers, tenant, worker, log };++ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, auth, viewers, tenant, worker, uploadGate, log };const app = express();app.disable('x-powered-by');@@@ -128,7 +132,13 @@ function createApp(opts = {}) {const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release });app.get('/api/ready', readiness.handler);app.get('/metrics', sharedMetrics.metricsHandler(registry));-- app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /$\nDisallow: /\n'));++ // The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites++ // never reach this app's routes; their robots.txt and sitemap are whatever the tenant uploads.++ const legalPaths = require('openvibe-shared/legal').PATHS;++ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')++ .send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));++ app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')++ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', ...legalPaths].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`));// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));@@@ -16,6 +16,7 @@ const MAX_PATH = 1024;const MAX_SEGMENT = 255;const MAX_DEPTH = 32;const SEGMENT_RE = /^[A-Za-z0-9._@+~()!,= -]+$/;++const CA_VALIDATION = new Set(['acme-challenge', 'pki-validation']);class PathError extends Error {constructor(code, message) { super(message); this.code = code; }@@@ -42,6 +43,12 @@ function checkPath(input) {}if (s.endsWith(' ') || s.startsWith(' ')) throw new PathError('path.invalid_character', `segment "${s}" starts or ends with a space`);});++ // Certificate authorities prove control of a host name with files under these paths (ACME++ // HTTP-01, and the file-based validation other CAs accept over HTTPS). A tenant must never be++ // able to obtain a certificate for <site>.openvibe.host: the operator issues every certificate.++ if (segments[0] === '.well-known' && CA_VALIDATION.has(String(segments[1]).toLowerCase())) {++ throw new PathError('path.reserved', `"${segments.slice(0, 2).join('/')}/" is reserved: certificate validation files are never published from a site`);++ }return input;}@@@ -42,7 +42,7 @@ function load(env = process.env) {baseUrl,dashboardHost,sitesDomain,-- trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1, // nginx (realip) → NodedbPath: env.HOST_DB_PATH || './data/host.db',// Content-addressed deploy objects, one directory per project (tenancy keyed by project id).@@@ -74,6 +74,8 @@ function load(env = process.env) {// unpacked files are held in memory while they are checked, so this bounds what a small// gzip bomb can cost, whatever quota staff grant a project.maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),++ // Uploads read and validated at the same time, service-wide (each is held in memory).++ maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),},// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).@@@ -15,6 +15,7 @@* host_activations every pointer switch (activate / rollback), for audit and rollback targets* host_deploy_logs the upload/validation log of each deploy (there is no build in Stage B)* host_domains default <site>.openvibe.host and custom domains (TXT-verified)++ * host_takedowns staff takedowns of a site or a project (serving stops; content is kept)* event_outbox openvibe-sdk transactional outbox** Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's@@@ -152,6 +153,21 @@ CREATE UNIQUE INDEX IF NOT EXISTS host_domains_verified ON host_domains (hostnamCREATE UNIQUE INDEX IF NOT EXISTS host_domains_site_host ON host_domains (site_id, hostname);CREATE INDEX IF NOT EXISTS host_domains_status ON host_domains (kind, status, last_checked_at);++-- Abuse takedowns (staff only): a site, or every site of a project, stops being served (451) while++-- its deploys, files and objects are KEPT for review. Rows are never deleted: a lift is recorded.++CREATE TABLE IF NOT EXISTS host_takedowns (++ id INTEGER PRIMARY KEY,++ target_kind TEXT NOT NULL CHECK (target_kind IN ('project','site')),++ target_id TEXT NOT NULL, -- prj_… or site_…++ reason TEXT NOT NULL,++ created_by TEXT NOT NULL,++ created_at INTEGER NOT NULL,++ lifted_by TEXT,++ lifted_at INTEGER,++ lift_note TEXT++);++CREATE UNIQUE INDEX IF NOT EXISTS host_takedowns_active ON host_takedowns (target_kind, target_id) WHERE lifted_at IS NULL;++-- Deploy artifacts are immutable: only state / deleted_at may change, and never back from 'deleted'.CREATE TRIGGER IF NOT EXISTS host_deploys_immutableBEFORE UPDATE OF id, project_id, site_id, source, manifest, manifest_sha256, file_count, total_bytes, new_bytes, created_by, created_at ON host_deploys@@@ -167,7 +183,7 @@ BEFORE DELETE ON host_deploy_files WHEN (SELECT state FROM host_deploys WHERE idBEGIN SELECT RAISE(ABORT, 'host: files of a live deploy cannot be removed'); END;`;--const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains'];++const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns'];function openStore(dbPath, { now = () => Date.now() } = {}) {if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });@@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');const DAY = 24 * 3600 * 1000;const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);--function createDeploys({ store, access, projects, sites, blobs, outbox, log = console }) {++function createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log = console }) {const { db } = store;const q = {byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),@@@ -74,6 +74,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co/** Before any byte is read: may this caller deploy to this site right now? */function precheck(viewer, siteId) {const { site, project } = sites.load(viewer, siteId, 'deploy');++ takedowns.assertOpen(site);const limits = limitsFor(project);const recent = q.deploysSince.get(project.id, store.now() - DAY).n;if (recent >= limits.quota.deploysPerDay) {@@@ -190,6 +191,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = cofunction activate(viewer, id, { expectedActive, traceparent } = {}) {const { deploy, site } = load(viewer, id, 'deploy');++ takedowns.assertOpen(site);const out = store.tx(() => switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));outbox.kick();return out;@@@ -198,6 +200,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co/** To `deployId`, or else to the most recent previously active deploy that still exists. */function rollback(viewer, siteId, { deployId, expectedActive, traceparent } = {}) {const { site } = sites.load(viewer, siteId, 'deploy');++ takedowns.assertOpen(site);const out = store.tx(() => {const current = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;let target = null;@@@ -236,6 +239,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = cofunction remove(viewer, id) {const { deploy, site, project } = load(viewer, id, 'maintain');++ takedowns.assertDeletable(viewer, { site });let freed = [];store.tx(() => {const active = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;@@@ -18,7 +18,7 @@ const PRINCIPAL_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|app:app_[0-9A-HJKMNP-TV-Z]{26const QUOTA_FIELDS = { storage_bytes: 'storageBytes', deploys_per_day: 'deploysPerDay', max_files: 'maxFiles', max_file_bytes: 'maxFileBytes', sites: 'sites', custom_domains: 'customDomains' };const DAY = 24 * 3600 * 1000;--function createProjects({ store, config, access, blobs, log = console }) {++function createProjects({ store, config, access, blobs, takedowns, log = console }) {const { db } = store;const q = {byId: db.prepare('SELECT * FROM host_projects WHERE id = ?'),@@@ -146,6 +146,7 @@ function createProjects({ store, config, access, blobs, log = console }) {/** Owner (or staff) deletes the project: every site stops serving at once; objects are removed. */function remove(viewer, project, { sites }) {access.authorize(project, viewer, 'own');++ takedowns.assertDeletable(viewer, { projectId: project.id });const now = store.now();store.tx(() => {for (const s of sites.listForProject(project.id)) sites.markDeleted(s, now);@@@ -21,7 +21,7 @@ const RESERVED = new Set([]);const HOLD_MS = 30 * 24 * 3600 * 1000;--function createSites({ store, config, access, projects }) {++function createSites({ store, config, access, projects, takedowns }) {const { db } = store;const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);const q = {@@@ -97,6 +97,7 @@ function createSites({ store, config, access, projects }) {function remove(viewer, id, { deploys }) {const { site, project } = load(viewer, id, 'maintain');++ takedowns.assertDeletable(viewer, { site });const now = store.now();let freed = [];store.tx(() => {nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * Abuse takedowns, by OpenVibe staff only (Network role admin).++ *++ * A takedown stops a site, or every site of a project, from being served at once: its default and++ * custom domains answer 451 with no tenant content. Unlike a delete, it KEEPS the deploys, their++ * file rows, logs and objects, so a report can be reviewed and, where the law requires it, the++ * material preserved and handed over. While a takedown is active the project's members cannot++ * upload, activate, roll back or delete anything it covers; staff can still delete. Every takedown++ * and every lift stays in host_takedowns.++ *++ * Members see that their site is taken down, and why (site/project `takedown` in the API).++ */++const { ApiError } = require('../http/errors');++const { principalOf } = require('./access');++++const REASON_MAX = 500;++++function createTakedowns({ store }) {++ const { db } = store;++ const q = {++ forSite: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns++ WHERE lifted_at IS NULL AND ((target_kind = 'site' AND target_id = ?) OR (target_kind = 'project' AND target_id = ?))++ ORDER BY target_kind = 'project' DESC, created_at LIMIT 1`),++ forProject: db.prepare("SELECT target_kind, reason, created_at FROM host_takedowns WHERE lifted_at IS NULL AND target_kind = 'project' AND target_id = ?"),++ anyInProject: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns t++ WHERE lifted_at IS NULL AND ((target_kind = 'project' AND target_id = @p)++ OR (target_kind = 'site' AND target_id IN (SELECT id FROM host_sites WHERE project_id = @p AND status = 'active')))++ LIMIT 1`),++ insert: db.prepare('INSERT INTO host_takedowns (target_kind, target_id, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),++ lift: db.prepare('UPDATE host_takedowns SET lifted_by = ?, lifted_at = ?, lift_note = ? WHERE target_kind = ? AND target_id = ? AND lifted_at IS NULL'),++ };++++ const shape = (row) => (row ? { scope: row.target_kind, reason: row.reason, since: row.created_at } : null);++++ /** The takedown that covers a site (its own, or its project's), or null. */++ const ofSite = (site) => shape(q.forSite.get(site.id, site.project_id));++ /** The project-wide takedown, or null. */++ const ofProject = (projectId) => shape(q.forProject.get(projectId));++++ /** Members may not publish into, or change, content that is taken down. */++ function assertOpen(site) {++ const t = ofSite(site);++ if (t) throw new ApiError(403, 'site.taken_down', `this ${t.scope} was taken down by OpenVibe staff (${t.reason}); nothing can be published or changed until staff lift it`);++ }++++ /** Deleting taken-down content would destroy what staff are reviewing: staff only. */++ function assertDeletable(viewer, { site = null, projectId = null }) {++ if (viewer && viewer.staff) return;++ const row = site ? q.forSite.get(site.id, site.project_id) : q.anyInProject.get({ p: projectId });++ if (row) throw new ApiError(409, 'site.taken_down', `this ${row.target_kind} was taken down by OpenVibe staff and is kept for review; it cannot be deleted until staff lift the takedown`);++ }++++ function requireStaff(viewer) {++ if (!viewer || !viewer.staff) throw new ApiError(403, 'auth.staff_only', 'only OpenVibe staff can take content down or lift a takedown');++ }++++ /** kind: 'site' | 'project'. -> the takedown */++ function takeDown(viewer, kind, targetId, reasonRaw) {++ requireStaff(viewer);++ const reason = String(reasonRaw || '').trim();++ if (!reason || reason.length > REASON_MAX) throw new ApiError(422, 'takedown.reason_required', `a takedown needs a reason of 1–${REASON_MAX} characters (members see it)`);++ try {++ q.insert.run(kind, targetId, reason, principalOf(viewer), store.now());++ } catch (err) {++ if (/UNIQUE/.test(err.message)) throw new ApiError(409, 'takedown.exists', `this ${kind} is already taken down`);++ throw err;++ }++ return { scope: kind, reason, since: store.now() };++ }++++ function lift(viewer, kind, targetId, note) {++ requireStaff(viewer);++ const r = q.lift.run(principalOf(viewer), store.now(), note == null ? null : String(note).slice(0, REASON_MAX), kind, targetId);++ if (r.changes !== 1) throw new ApiError(404, 'takedown.not_found', `this ${kind} is not taken down`);++ return { lifted: true };++ }++++ return { ofSite, ofProject, assertOpen, assertDeletable, takeDown, lift };++}++++module.exports = { createTakedowns };@@@ -13,6 +13,7 @@* DELETE /deploys/:id host.site.manage* domains GET/POST /sites/:id/domains · POST /domains/:id/verify* DELETE /domains/:id host.domain.manage++ * takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown host.site.manage (staff)*/const express = require('express');const contracts = require('openvibe-contracts');@@@ -24,7 +25,7 @@ const out = require('./serialize');const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());function createApi(ctx) {-- const { config, projects, sites, deploys, domains, viewers } = ctx;++ const { config, projects, sites, deploys, domains, viewers, takedowns } = ctx;const router = express.Router();// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).@@@ -47,8 +48,8 @@ function createApi(ctx) {const u = new URL(config.baseUrl);return `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;};-- const siteOut = (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname) }); };-- const projectOut = (p, role) => out.project(p, { role, quota: projects.quotaOf(p), usage: projects.usageOf(p) });++ const siteOut = (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname), takedown: takedowns.ofSite(s) }); };++ const projectOut = (p, role) => out.project(p, { role, quota: projects.quotaOf(p), usage: projects.usageOf(p), takedown: takedowns.ofProject(p.id) });const tp = (req) => (req.ov ? req.ov.traceparent : undefined);const loadProject = (req, need) => {const project = projects.get(req.params.id);@@@ -59,7 +60,7 @@ function createApi(ctx) {// ── Projects ────────────────────────────────────────────router.get('/projects', guard('host.site.manage'), run((req) => {if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');-- return { projects: projects.listFor(req.viewer).map((p) => out.project(p)) };++ return { projects: projects.listFor(req.viewer).map((p) => out.project(p, { takedown: takedowns.ofProject(p.id) })) };}));router.post('/projects', guard('host.site.manage'), jsonBody, run((req) => {if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');@@@ -94,6 +95,25 @@ function createApi(ctx) {return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };}));++ // ── Takedowns (staff only; members see them on the project and site) ──++ // Serving stops at once and the content is kept for review (domain/takedowns.js).++ router.post('/projects/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {++ const { project } = loadProject(req, 'read');++ return { takedown: out.takedownOut(takedowns.takeDown(req.viewer, 'project', project.id, (req.body || {}).reason)) };++ }, 201));++ router.delete('/projects/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {++ const { project } = loadProject(req, 'read');++ return takedowns.lift(req.viewer, 'project', project.id, (req.body || {}).note);++ }));++ router.post('/sites/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {++ const { site } = sites.load(req.viewer, req.params.id, 'read');++ return { takedown: out.takedownOut(takedowns.takeDown(req.viewer, 'site', site.id, (req.body || {}).reason)) };++ }, 201));++ router.delete('/sites/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {++ const { site } = sites.load(req.viewer, req.params.id, 'read');++ return takedowns.lift(req.viewer, 'site', site.id, (req.body || {}).note);++ }));++// ── Sites ───────────────────────────────────────────────router.get('/projects/:id/sites', guard('host.site.manage'), run((req) => {const { project } = loadProject(req, 'read');@@@ -112,7 +132,8 @@ function createApi(ctx) {// ── Deploys ─────────────────────────────────────────────router.get('/sites/:id/deploys', guard('host.deploy.create'), run((req) => {-- const { site } = sites.load(req.viewer, req.params.id, 'deploy');++ // Reads need the least member role (deployer); staff may read them too, e.g. to review a takedown.++ const { site } = sites.load(req.viewer, req.params.id, 'read');return {active_deploy_id: site.active_deploy_id || null,deploys: deploys.list(site.id, Number(req.query.limit) || 50).map((d) => out.deploy(d, { active: d.id === site.active_deploy_id })),@@@ -121,8 +142,11 @@ function createApi(ctx) {}));router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {++ let release = null;try {const pre = deploys.precheck(req.viewer, req.params.id);++ release = ctx.uploadGate.enter();++ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Host is validating other uploads right now; try again in 30 seconds'); }let up;try {up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });@@@ -138,18 +162,20 @@ function createApi(ctx) {const site = sites.get(pre.site.id);res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: Boolean(r.activated && r.activated.changed), url: siteUrl(sites.defaultHostname(site.name)) });} catch (err) {-- // Refused before the body was read (404, 429): close instead of draining a large upload.++ // Refused before the body was read (404, 429, 503): close instead of draining a large upload.if (!req.complete) res.set('Connection', 'close');if (!res.headersSent) sendError(res, req, err);++ } finally {++ if (release) release();}});router.get('/deploys/:id', guard('host.deploy.create'), run((req) => {-- const { deploy, site } = deploys.load(req.viewer, req.params.id, 'deploy');++ const { deploy, site } = deploys.load(req.viewer, req.params.id, 'read');return { deploy: out.deploy(deploy, { active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id) }) };}));router.get('/deploys/:id/log', guard('host.deploy.create'), run((req) => {-- const { deploy } = deploys.load(req.viewer, req.params.id, 'deploy');++ const { deploy } = deploys.load(req.viewer, req.params.id, 'read');return { deploy_id: deploy.id, state: deploy.state, log: deploys.logOf(deploy.id).map(out.logLine) };}));router.post('/deploys/:id/activate', guard('host.deploy.create'), jsonBody, run((req) => {@@@ -25,8 +25,8 @@ function createDashboard(ctx) {router.use(viewers.middleware('dashboard'));router.use((req, res, next) => { privateNoStore(res); next(); });-- function page(req, res, status, title, body, notice) {-- res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice }));++ function page(req, res, status, title, body, notice, { indexable = false } = {}) {++ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable }));}function fail(req, res, err) {@@@ -56,11 +56,15 @@ function createDashboard(ctx) {const n = req.query.notice;return typeof n === 'string' && n.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: n } : null;};++ /** A staff takedown outranks any other notice: members must see why nothing is served. */++ const takedownNotice = (t) => (t ? { kind: 'error', text: `This ${t.scope} was taken down by OpenVibe staff: ${t.reason}. Nothing of it is served, and nothing can be published or deleted, until staff lift the takedown.` } : null);const back = (path, text, kind) => `${path}?notice=${encodeURIComponent(text)}${kind ? `&kind=${kind}` : ''}`;// ── Pages ───────────────────────────────────────────────router.get('/', view((req, res) => {-- if (req.viewer.kind !== 'user') return page(req, res, 200, null, pages.signedOut());++ // The signed-out front page is the public page of openvibe.host (sitemap.xml): indexable.++ // Everything behind sign-in stays noindex and private.++ if (req.viewer.kind !== 'user') return page(req, res, 200, null, pages.signedOut(), null, { indexable: !req.query || !Object.keys(req.query).length });page(req, res, 200, 'Projects', pages.home({ projects: projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer) }), notice(req));}));@@@ -71,7 +75,7 @@ function createDashboard(ctx) {page(req, res, 200, project.name, pages.project({project, role, sites: sites.listForProject(project.id), quota: projects.quotaOf(project), usage: projects.usageOf(project),members: projects.members(project), csrf: csrfToken(config, req.viewer), siteUrl,-- }), notice(req));++ }), takedownNotice(ctx.takedowns.ofProject(project.id)) || notice(req));}));router.get('/sites/:id', view((req, res) => {@@@ -81,12 +85,12 @@ function createDashboard(ctx) {site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer),deploys: deploys.list(site.id, 50), activations: deploys.activationsOf(site.id, 10),domains: domains.listForSite(site.id).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),-- }), notice(req));++ }), takedownNotice(ctx.takedowns.ofSite(site)) || notice(req));}));router.get('/deploys/:id', view((req, res) => {if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');-- const { deploy, site, project } = deploys.load(req.viewer, req.params.id, 'deploy');++ const { deploy, site, project } = deploys.load(req.viewer, req.params.id, 'read');page(req, res, 200, `Deploy ${deploy.id.slice(0, 12)}`, pages.deploy({deploy, site, project, active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id), log: deploys.logOf(deploy.id),}), notice(req));@@@ -158,10 +162,13 @@ function createDashboard(ctx) {// Upload: multipart; the form token is checked after the body is parsed (Origin before).router.post('/sites/:id/deploys', async (req, res) => {++ let release = null;try {if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this dashboard');const pre = deploys.precheck(req.viewer, req.params.id);++ release = ctx.uploadGate.enter();++ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Host is validating other uploads right now; try again in 30 seconds'); }let up;try {up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });@@@ -183,6 +190,8 @@ function createDashboard(ctx) {} catch (err) {if (!req.complete) res.set('Connection', 'close');fail(req, res, err);++ } finally {++ if (release) release();}});@@@ -8,7 +8,12 @@*/const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());--function project(p, { role = null, quota = null, usage = null } = {}) {++/** A staff takedown as members see it (domain/takedowns.js), or null. */++function takedownOut(t) {++ return t ? { scope: t.scope, reason: t.reason, since: iso(t.since) } : null;++}++++function project(p, { role = null, quota = null, usage = null, takedown = null } = {}) {return {id: p.id,name: p.name,@@@ -17,6 +22,7 @@ function project(p, { role = null, quota = null, usage = null } = {}) {owner: p.owner_subject,role: role || p.role || null,created_at: iso(p.created_at),++ takedown: takedownOut(takedown),...(quota ? { quota: quotaOut(quota) } : {}),...(usage ? { usage: usageOut(usage) } : {}),};@@@ -33,7 +39,7 @@ function usageOut(u) {return { storage_bytes: u.storageBytes, objects: u.objects, deploys_last_24h: u.deploysLast24h, sites: u.sites, custom_domains: u.customDomains };}--function site(s, { hostname, url }) {++function site(s, { hostname, url, takedown = null }) {return {id: s.id,project_id: s.project_id,@@@ -41,6 +47,7 @@ function site(s, { hostname, url }) {hostname,url,active_deploy_id: s.active_deploy_id || null,++ takedown: takedownOut(takedown),created_at: iso(s.created_at),updated_at: iso(s.updated_at),};@@@ -89,4 +96,4 @@ function activation(a) {return { deploy_id: a.deploy_id, previous_deploy_id: a.previous_deploy_id || null, kind: a.kind, actor: a.actor, at: iso(a.created_at) };}--module.exports = { project, quotaOut, usageOut, site, deploy, logLine, domain, activation, iso };++module.exports = { project, takedownOut, quotaOut, usageOut, site, deploy, logLine, domain, activation, iso };@@@ -15,6 +15,7 @@* Responses: GET/HEAD only; Content-Type from the manifest; strong ETag (the sha256); 304 on* If-None-Match; single byte ranges; `immutable` caching for fingerprinted assets and revalidation* for everything else (so a rollback shows at once); nosniff; a strict CSP; never a cookie.++ * A site that staff took down (domain/takedowns.js) answers 451 with none of its content.*/const fs = require('fs');const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');@@@ -74,7 +75,7 @@ function plain(res, status, text, extra = {}) {res.end(text);}--function createTenantServer({ store, config, blobs, log = console }) {++function createTenantServer({ store, config, blobs, takedowns = null, log = console }) {const { db } = store;const q = {siteByName: db.prepare("SELECT s.*, p.environment FROM host_sites s JOIN host_projects p ON p.id = s.project_id WHERE s.name = ? AND s.status = 'active' AND p.status = 'active'"),@@@ -178,6 +179,10 @@ function createTenantServer({ store, config, blobs, log = console }) {if (req.method !== 'GET' && req.method !== 'HEAD') {return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });}++ // Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.++ if (takedowns && takedowns.ofSite(site)) {++ return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.');++ }// Absolute-form request targets must agree with the Host header (or they would pick the site).let target = req.url || '/';if (!target.startsWith('/')) {@@@ -196,8 +201,12 @@ function createTenantServer({ store, config, blobs, log = console }) {for (const p of c.list) {const row = q.file.get(deployId, p);if (row && row.project_id === site.project_id) {-- const cache = isHashedAsset(p) ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate';-- return send(req, res, site, row, { cache });++ const hashed = isHashedAsset(p);++ // Browsers keep fingerprinted assets for a year; a shared CDN in front of the tenant++ // vhost (Cloudflare) keeps them for an hour at most, so a takedown, deletion or++ // rollback leaves the edge within the hour even if nobody purges it.++ if (hashed) res.setHeader('CDN-Cache-Control', 'public, max-age=3600');++ return send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });}}if (!c.dir && c.dirIndex && q.file.get(deployId, c.dirIndex)) {@@@ -172,4 +172,25 @@ async function readUpload(req, { maxUploadBytes, maxUnpackedBytes = Infinity, lireturn { source, entries, fields, notes };}--module.exports = { readUpload, UploadError };++/**++ * At most `max` uploads are read and validated at once, service-wide. An upload is held in memory++ * (up to HOST_MAX_UPLOAD_BYTES, plus up to HOST_MAX_UNPACKED_BYTES once unpacked), so without a cap++ * a handful of accounts uploading together could exhaust the host's memory. enter() -> release()++ * or null when every slot is taken (the caller answers 503 with Retry-After before reading a byte).++ */++function createUploadGate(max) {++ const limit = Math.max(1, Number(max) || 1);++ let inFlight = 0;++ return {++ enter() {++ if (inFlight >= limit) return null;++ inFlight++;++ let released = false;++ return () => { if (!released) { released = true; inFlight--; } };++ },++ get inFlight() { return inFlight; },++ limit,++ };++}++++module.exports = { readUpload, UploadError, createUploadGate };@@@ -53,6 +53,7 @@ function renderPage(o) {<meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME)}</title><meta name="robots" content="${o.indexable ? 'index, follow' : 'noindex, nofollow'}">++${o.indexable ? `<link rel="canonical" href="${esc(`${o.config.baseUrl}${o.path && o.path.split('?')[0] !== '/' ? o.path.split('?')[0] : '/'}`)}">` : ''}<meta name="description" content="OpenVibe.Host: static site hosting for OpenVibe projects (alpha).">${appIcon.headTags({ site: 'host' })}<link rel="stylesheet" href="${asset('css/host.css')}">@@@ -7,7 +7,8 @@proxy_set_header Connection "";proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;proxy_set_header Last-Event-ID $http_last_event_id;proxy_buffering off;@@@ -7,7 +7,8 @@proxy_set_header Connection ${{zone}}_connection;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;proxy_buffering off;proxy_read_timeout 1h;@@@ -13,7 +13,8 @@proxy_set_header Cookie "";proxy_set_header Authorization "";proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;proxy_hide_header Set-Cookie;proxy_read_timeout 60s;@@@ -44,7 +44,8 @@ server {proxy_set_header Connection "";proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;location /auth/ {@@@ -76,7 +77,7 @@ server {}}--# ── Tenant sites ───────────────────────────────────────────++{{wwwRedirect}}# ── Tenant sites ───────────────────────────────────────────server {listen 443 ssl;listen [::]:443 ssl;@@@ -46,7 +46,8 @@ server {proxy_set_header Connection "";proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;proxy_read_timeout 60s;}nnew file mode 100644@@@ -0,0 +1,15 @@++# ── {{www}} → {{dashboard}} ─────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name {{www}};++++ ssl_certificate {{certDir}}/fullchain.pem;++ ssl_certificate_key {{certDir}}/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ add_header Strict-Transport-Security "max-age=31536000" always;++ return 301 https://{{dashboard}}$request_uri;++}++nnew file mode 100644@@@ -0,0 +1,170 @@++'use strict';++/**++ * Abuse controls (docs/threat-review.md): staff takedowns stop serving at once and keep the content++ * for review; members cannot publish, switch or delete around a takedown; certificate-validation++ * paths cannot be published; uploads are validated a few at a time, so concurrent uploads cannot++ * exhaust memory; fingerprinted assets leave a shared CDN within the hour.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const { boot, check, done } = require('./stageb/boot');++const { site: siteTar } = require('./stageb/tar');++++(async () => {++ const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1' } });++ const alice = t.user('alice');++ const staff = t.user('root', { role: 'admin' });++ const pa = await t.project(alice, 'A');++ const alpha = await t.site(alice, pa.id, 'alpha');++ const other = await t.site(alice, pa.id, 'other');++ const d1 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA ONE', 'app.3f2a9c1b.js': 'js();' });++ const d2 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA TWO' });++ await t.deploy(alice, other.id, { 'index.html': 'OTHER' });++ const dom = await t.api('POST', `/api/v1/sites/${alpha.id}/domains`, { as: alice, json: { hostname: 'www.alpha-example.org' } });++ const domain = dom.json().domain;++ t.dns.set("_openvibe-host.www.alpha-example.org", [domain.instructions.verification.value]);++ assert.strictEqual((await t.api('POST', `/api/v1/domains/${domain.id}/verify`, { as: alice })).json().domain.status, 'verified');++ const objectsOf = (p) => { const out = []; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(path.join(d, e.name)); else out.push(e.name); } }; walk(path.join(t.dir, 'objects', 'projects', p)); return out.sort(); };++++ await check('fingerprinted assets: immutable for browsers, at most an hour at a shared CDN', async () => {++ const r = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');++ await t.api('POST', `/api/v1/deploys/${d1.id}/activate`, { as: alice });++ const r1 = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');++ assert.strictEqual(r.status, 404, 'not in the active deploy yet');++ assert.strictEqual(r1.headers['cache-control'], 'public, max-age=31536000, immutable');++ assert.strictEqual(r1.headers['cdn-cache-control'], 'public, max-age=3600');++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).headers['cdn-cache-control'], undefined);++ await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice });++ });++++ await check('only staff take a site down; members and strangers cannot', async () => {++ const mine = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: alice, json: { reason: 'self' } });++ assert.strictEqual(mine.status, 403);++ assert.strictEqual(mine.json().code, 'auth.staff_only');++ const stranger = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: t.user('mallory'), json: { reason: 'x' } });++ assert.strictEqual(stranger.status, 404, 'a stranger learns nothing');++ const noReason = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: {} });++ assert.strictEqual(noReason.status, 422);++ });++++ const objectsBefore = objectsOf(pa.id);++ await check('a takedown stops the default and custom domains at once (451, no tenant bytes) and keeps everything', async () => {++ const r = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'phishing report #42' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().takedown.reason, 'phishing report #42');++ for (const host of ['alpha.openvibe.host', 'www.alpha-example.org']) {++ for (const p of ['/', '/index.html', '/app.3f2a9c1b.js', '/404.html', '/missing']) {++ const g = await t.get(host, p);++ assert.strictEqual(g.status, 451, `${host}${p}`);++ assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');++ assert.strictEqual(g.headers['cache-control'], 'no-store');++ assert.strictEqual(g.headers.etag, undefined);++ }++ }++ assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');++ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore, 'every object kept');++ const files = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: staff });++ assert.strictEqual(files.json().deploy.files.length, 2, 'file rows kept for review');++ assert.strictEqual((await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'again' } })).status, 409);++ });++++ await check('members see the takedown and why, and cannot publish, switch or delete around it', async () => {++ const s = await t.api('GET', `/api/v1/sites/${alpha.id}`, { as: alice });++ assert.deepStrictEqual([s.json().site.takedown.scope, s.json().site.takedown.reason], ['site', 'phishing report #42']);++ const up = await t.upload(alice, alpha.id, { 'index.html': 'EVADE' });++ assert.strictEqual(up.status, 403);++ assert.strictEqual(up.json().code, 'site.taken_down');++ for (const [method, p, json] of [++ ['POST', `/api/v1/deploys/${d1.id}/activate`, {}], ['POST', `/api/v1/sites/${alpha.id}/rollback`, {}],++ ['DELETE', `/api/v1/deploys/${d1.id}`], ['DELETE', `/api/v1/sites/${alpha.id}`], ['DELETE', `/api/v1/projects/${pa.id}`],++ ]) {++ const r = await t.api(method, p, { as: alice, json });++ assert.ok([403, 409].includes(r.status), `${method} ${p} → ${r.status} ${r.text}`);++ assert.strictEqual(r.json().code, 'site.taken_down');++ }++ const page = await t.api('GET', `/sites/${alpha.id}`, { session: alice });++ assert.match(page.text, /taken down by OpenVibe staff: phishing report #42/);++ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore);++ });++++ await check('staff lift a takedown: the same content serves again; members only lift nothing', async () => {++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: alice })).status, 403);++ const r = await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { note: 'reviewed: false positive' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).text, 'ALPHA TWO');++ assert.strictEqual((await t.get('www.alpha-example.org', '/')).text, 'ALPHA TWO');++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 404);++ const rows = t.ctx.store.db.prepare('SELECT target_kind, reason, lifted_at IS NOT NULL AS lifted, lift_note FROM host_takedowns').all();++ assert.deepStrictEqual(rows, [{ target_kind: 'site', reason: 'phishing report #42', lifted: 1, lift_note: 'reviewed: false positive' }], 'the history is kept');++ });++++ await check('a project takedown covers every site of the project; staff can still delete', async () => {++ const r = await t.api('POST', `/api/v1/projects/${pa.id}/takedown`, { as: staff, json: { reason: 'spam network' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).status, 451);++ assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 451);++ const p = await t.api('GET', `/api/v1/projects/${pa.id}`, { as: alice });++ assert.strictEqual(p.json().project.takedown.scope, 'project');++ assert.strictEqual(p.json().sites[0].takedown.scope, 'project');++ const list = await t.api('GET', '/api/v1/projects', { as: alice });++ assert.strictEqual(list.json().projects[0].takedown.reason, 'spam network');++ assert.strictEqual((await t.upload(alice, other.id, { 'index.html': 'x' })).status, 403);++ const del = await t.api('DELETE', `/api/v1/sites/${other.id}`, { as: staff });++ assert.strictEqual(del.status, 200, del.text);++ assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 404);++ });++++ await check('certificate-validation paths are never published (no tenant can get a certificate for its host name)', async () => {++ const pb = await t.project(alice, 'B');++ const beta = await t.site(alice, pb.id, 'beta');++ for (const p of ['.well-known/acme-challenge/token123', '.well-known/pki-validation/fileauth.txt', '.well-known/ACME-CHALLENGE/x']) {++ const r = await t.upload(alice, beta.id, { 'index.html': 'B', [p]: 'proof' });++ assert.strictEqual(r.status, 422, `${p} → ${r.status}`);++ assert.ok(r.json().log.some((l) => /reserved/.test(l)), r.text);++ }++ await t.deploy(alice, beta.id, { 'index.html': 'B', '.well-known/security.txt': 'Contact: mailto:[email protected]' });++ assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/security.txt')).status, 200, 'other .well-known files still publish');++ assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/acme-challenge/token123')).status, 404);++ });++++ await check('uploads are validated a few at a time: a second concurrent upload gets 503 + Retry-After and is not a failed deploy', async () => {++ const pc = await t.project(alice, 'C');++ const gamma = await t.site(alice, pc.id, 'gamma');++ const body = siteTar({ 'index.html': 'GAMMA' });++ const failedBefore = t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get().n;++ // The first upload sends its headers and half its body, then waits.++ let firstResponse;++ let finish;++ const first = new Promise((resolve) => {++ const req = http.request({ host: '127.0.0.1', port: t.port, method: 'POST', path: `/api/v1/sites/${gamma.id}/deploys?activate=1`, headers: { host: 'openvibe.host', authorization: `Bearer ${t.network.userToken(alice)}`, 'content-type': 'application/gzip', 'content-length': body.length } }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => { firstResponse = { status: res.statusCode, text: Buffer.concat(chunks).toString() }; resolve(); });++ });++ req.write(body.subarray(0, Math.floor(body.length / 2)));++ finish = () => req.end(body.subarray(Math.floor(body.length / 2)));++ });++ await new Promise((r) => setTimeout(r, 150));++ assert.strictEqual(t.ctx.uploadGate.inFlight, 1);++ const second = await t.upload(alice, gamma.id, { 'index.html': 'SECOND' });++ assert.strictEqual(second.status, 503, second.text);++ assert.strictEqual(second.json().code, 'upload.busy');++ assert.strictEqual(second.headers['retry-after'], '30');++ finish();++ await first;++ assert.strictEqual(firstResponse.status, 201, firstResponse.text);++ assert.strictEqual(t.ctx.uploadGate.inFlight, 0, 'the slot is released');++ assert.strictEqual(t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get().n, failedBefore, 'a busy refusal is not the tenant\'s failed deploy');++ const third = await t.upload(alice, gamma.id, { 'index.html': 'THIRD' });++ assert.strictEqual(third.status, 201, third.text);++ // A refused upload releases its slot too.++ const bad = await t.upload(alice, gamma.id, { '../x.html': 'no' });++ assert.strictEqual(bad.status, 422);++ assert.strictEqual(t.ctx.uploadGate.inFlight, 0);++ });++++ await t.close();++ done();++})();@@@ -1,8 +1,10 @@'use strict';/*** Wave 21 exit criterion: "a hosted static project cannot read another tenant's objects".-- * Path tricks, encoded traversal, Host header tricks, absolute-form targets, shared content, and-- * the API: a tenant only ever reaches its own site, deploys and domains.++ * Path tricks, encoded traversal, Host header tricks, absolute-form targets, shared content, the API,++ * cross-origin reads from a tenant page (no CORS grant anywhere), existence oracles (ETag, Range),++ * delegated service tokens, app principals and the dashboard: a tenant only ever reaches its own++ * site, deploys and domains.*/const assert = require('assert');const fs = require('fs');@@@ -135,6 +137,65 @@ const { boot, check, done } = require('./stageb/boot');assert.strictEqual(r.headers['content-type'], 'application/problem+json');});++ await check('a tenant page\'s scripts cannot read another tenant\'s files: no CORS grant on tenant responses, preflights refused', async () => {++ for (const origin of ['https://alpha.openvibe.host', 'null', 'https://evil.example']) {++ const r = await t.get('beta.openvibe.host', '/secret.html', { headers: { origin } });++ assert.strictEqual(r.status, 200);++ for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith('access-control-'), `beta answered ${origin} with ${h}`);++ const pre = await t.request({ method: 'OPTIONS', host: 'beta.openvibe.host', path: '/secret.html', headers: { origin, 'access-control-request-method': 'GET' } });++ assert.strictEqual(pre.status, 405);++ assert.ok(!Object.keys(pre.headers).some((h) => h.startsWith('access-control-')));++ }++ assert.strictEqual((await t.get('beta.openvibe.host', '/secret.html')).headers['cross-origin-opener-policy'], 'same-origin');++ });++++ await check('the Host API grants CORS to no tenant origin (a tenant page cannot call it from a visitor\'s browser)', async () => {++ for (const origin of ['https://alpha.openvibe.host', 'https://beta.openvibe.host', 'https://openvibe.host.evil.example']) {++ const r = await t.api('GET', '/api/v1/projects', { as: bob, headers: { origin } });++ assert.strictEqual(r.headers['access-control-allow-origin'], undefined, origin);++ const pre = await t.request({ method: 'OPTIONS', host: 'openvibe.host', path: '/api/v1/projects', headers: { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });++ assert.strictEqual(pre.headers['access-control-allow-origin'], undefined, `preflight from ${origin}`);++ }++ });++++ await check('no existence oracle: another tenant\'s sha256 as If-None-Match or a Range on alpha is still a 404', async () => {++ const r = await t.get('alpha.openvibe.host', '/secret.html', { headers: { 'if-none-match': `"${secretSha}"` } });++ assert.strictEqual(r.status, 404);++ const r2 = await t.get('alpha.openvibe.host', `/${secretSha}`, { headers: { 'if-none-match': `"${secretSha}"`, range: 'bytes=0-3' } });++ assert.strictEqual(r2.status, 404);++ assert.ok(!leaks(r) && !leaks(r2));++ });++++ await check('delegation and app principals stay inside their own project', async () => {++ const codes = t.network.serviceToken('codes', ['host.site.manage', 'host.deploy.create', 'host.domain.manage']);++ for (const p of [`/api/v1/projects/${pb.id}`, `/api/v1/deploys/${db.id}`, `/api/v1/deploys/${db.id}/log`, `/api/v1/sites/${beta.id}/deploys`]) {++ const r = await t.api('GET', p, { as: codes, headers: { 'x-ov-subject': alice.subject } });++ assert.strictEqual(r.status, 404, `svc:codes for alice → ${p} ${r.status}`);++ assert.ok(!leaks(r) && !r.text.includes(secretSha));++ }++ const ci = t.network.appToken('app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', ['host.deploy.create', 'host.site.manage']);++ const add = await t.api('PUT', `/api/v1/projects/${pa.id}/members/app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3`, { as: alice, json: { role: 'deployer' } });++ assert.strictEqual(add.status, 200, add.text);++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/deploys`, { as: ci })).status, 200, 'the app reads its own project');++ for (const p of [`/api/v1/deploys/${db.id}`, `/api/v1/sites/${beta.id}/deploys`]) assert.strictEqual((await t.api('GET', p, { as: ci })).status, 404, p);++ const up = await t.api('POST', `/api/v1/sites/${beta.id}/deploys?activate=1`, { as: ci, body: require('./stageb/tar').site({ 'index.html': 'PWNED' }), headers: { 'content-type': 'application/gzip' } });++ assert.strictEqual(up.status, 404);++ // X-OV-Subject is honoured for first-party services only: an app cannot borrow bob's identity.++ const borrow = await t.api('GET', `/api/v1/deploys/${db.id}`, { as: ci, headers: { 'x-ov-subject': bob.subject } });++ assert.strictEqual(borrow.status, 400);++ assert.strictEqual(borrow.json().code, 'subject.not_delegable');++ assert.ok(!leaks(borrow));++ assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');++ });++++ await check('the dashboard (cookie session) shows a non-member nothing of another tenant: no names, files or hashes', async () => {++ for (const p of [`/projects/${pb.id}`, `/sites/${beta.id}`, `/deploys/${db.id}`]) {++ const r = await t.api('GET', p, { session: alice });++ assert.strictEqual(r.status, 404, `${p} → ${r.status}`);++ assert.ok(!/secret\.html|BETA|beta-example/.test(r.text) && !r.text.includes(secretSha), `${p} leaked`);++ }++ });++await t.close();done();})();@@@ -57,7 +57,26 @@ const ORIGIN = 'https://openvibe.host';assert.match(r.headers['content-security-policy'], /frame-ancestors 'none'/);assert.strictEqual(r.headers['x-frame-options'], 'DENY');assert.strictEqual(r.headers['cache-control'], 'private, no-store');-- assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);++ // The public front page is indexable, with a canonical URL; nothing behind sign-in is.++ assert.match(r.text, /<meta name="robots" content="index, follow">/);++ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.host\/">/);++ const signedIn = await t.api('GET', '/', { session: alice });++ assert.match(signedIn.text, /<meta name="robots" content="noindex, nofollow">/);++ assert.ok(!/rel="canonical"/.test(signedIn.text));++ const withQuery = await t.api('GET', '/?notice=hi');++ assert.match(withQuery.text, /<meta name="robots" content="noindex, nofollow">/);++ });++++ await check('robots.txt and sitemap.xml: the front page and the legal pages only; tenant sites keep their own', async () => {++ const robots = await t.api('GET', '/robots.txt');++ assert.strictEqual(robots.status, 200);++ assert.strictEqual(robots.text, 'User-agent: *\nAllow: /$\nAllow: /terms$\nAllow: /privacy$\nAllow: /dmca$\nDisallow: /\n\nSitemap: https://openvibe.host/sitemap.xml\n');++ const map = await t.api('GET', '/sitemap.xml');++ assert.strictEqual(map.status, 200);++ assert.match(map.headers['content-type'], /application\/xml/);++ const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ assert.deepStrictEqual(locs, ['https://openvibe.host/', 'https://openvibe.host/terms', 'https://openvibe.host/privacy', 'https://openvibe.host/dmca']);++ for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);});await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {@@@ -84,6 +84,64 @@ runTests([assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null);}),++ test('launch: --install removes the interim tenants-pending vhost in the same nginx -t and reload; a failed test brings it back', async () => {++ const PENDING = '# *.openvibe.host before Host Stage B tenant hosting launches\nserver { server_name *.openvibe.host; return 404; }\n';++ const SITES = '# openvibe.host — static front page (OpenVibe.Sites)\nserver { server_name openvibe.host; root /opt/openvibe.sites/dist/openvibe.host; }\n';++ const prepare = (host) => {++ host.put('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', PENDING);++ host.files.set('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', owner: 'root', mode: 0o777 });++ host.put('/etc/nginx/sites-available/openvibe.host.conf', SITES);++ host.files.set('/etc/nginx/sites-enabled/openvibe.host.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host.conf', owner: 'root', mode: 0o777 });++ return host;++ };++ const review = await prepare(withHost(scenario(), [])).cli('nginx', 'tenants', 'host');++ assert.match(review.out, /--install also removes, in the same nginx -t and reload: openvibe\.host-tenants-pending\.conf/);++++ const host = prepare(withHost(scenario(), []));++ let seenAtTest = null;++ host.nginxTest = () => {++ seenAtTest = { pending: host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'), main: host.read('/etc/nginx/sites-available/openvibe.host.conf') };++ return { code: 0, stderr: 'ok' };++ };++ const r = await host.cli('nginx', 'tenants', 'host', '--install');++ assert.strictEqual(r.code, 0, r.out);++ assert.deepStrictEqual([seenAtTest.pending, /server_name \*\.openvibe\.host;/.test(seenAtTest.main)], [false, true], 'one nginx -t sees the tenant vhost and no pending vhost');++ assert.ok(!host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'));++ assert.ok(!host.files.has('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'));++ assert.match(r.out, /removed openvibe\.host-tenants-pending\.conf/);++ assert.strictEqual(host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length, 1, 'one reload');++ const again = await host.cli('nginx', 'tenants', 'host', '--install');++ assert.match(again.out, /already installed; nothing changed/);++++ const bad = prepare(withHost(scenario(), []));++ bad.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] cannot load certificate' });++ const f = await bad.cli('nginx', 'tenants', 'host', '--install');++ assert.strictEqual(f.code, 2);++ assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'), PENDING, 'pending vhost restored');++ assert.strictEqual(bad.files.get('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf').target, '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf');++ assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host.conf'), SITES, 'the Sites placeholder vhost restored');++ assert.ok(!bad.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'not reloaded');++ }),++++ test('client address headers come from $remote_addr only (realip), in every template ovhost renders', async () => {++ const fs = require('fs');++ const path = require('path');++ const dir = path.join(__dirname, '..', 'templates', 'nginx');++ for (const f of fs.readdirSync(dir)) {++ const t = fs.readFileSync(path.join(dir, f), 'utf8');++ assert.ok(!/\$proxy_add_x_forwarded_for|\$http_x_forwarded_for|\$http_cf_connecting_ip/.test(t), `${f} passes a client-supplied address on`);++ if (/X-Forwarded-For/.test(t)) assert.match(t, /X-Forwarded-For \$remote_addr;/, f);++ }++ const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');++ assert.ok(!/proxy_add_x_forwarded_for/.test(r.out));++ assert.match(r.out, /proxy_set_header CF-Connecting-IP \$remote_addr;/);++ }),++++ test('www.openvibe.host redirects to the dashboard over the wildcard certificate', async () => {++ const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');++ assert.match(r.out, /server_name www\.openvibe\.host;\n\n ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.host\/fullchain\.pem;[\s\S]*?return 301 https:\/\/openvibe\.host\$request_uri;/);++ }),++test('refuses a service without a tenants block, a bad certificate path, and sites under a first-party domain', async () => {const host = withHost(scenario(), []);assert.notStrictEqual((await host.cli('nginx', 'tenants', 'media')).code, 0);
@@ -10,8 +10,9 @@ HOST=127.0.0.1
@@ -10,8 +10,9 @@ HOST=127.0.0.1
NODE_ENV=development
NODE_ENV=development
# The dashboard + API origin (production: https://openvibe.host)
# The dashboard + API origin (production: https://openvibe.host)
BASE_URL=http://localhost:4910
BASE_URL=http://localhost:4910
-# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
-TRUST_PROXY=2
+# Proxy hops in front of Node that set X-Forwarded-For. nginx resolves the Cloudflare hop itself
+# (realip) and sends only $remote_addr, so there is one: nginx → Node = 1.
+TRUST_PROXY=1
# Tenant sites are served at <site>.<HOST_SITES_DOMAIN>. It must be its own registrable domain
# Tenant sites are served at <site>.<HOST_SITES_DOMAIN>. It must be its own registrable domain
# (never under openvibe.network / openvibe.live). Development: localhost (http://<site>.localhost:4910)
# (never under openvibe.network / openvibe.live). Development: localhost (http://<site>.localhost:4910)
@@ -43,6 +44,9 @@ HOST_FORM_SECRET=
@@ -43,6 +44,9 @@ HOST_FORM_SECRET=
#HOST_MAX_UPLOAD_BYTES=104857600
#HOST_MAX_UPLOAD_BYTES=104857600
# Largest size an uploaded archive may unpack to (default 256 MiB; the storage quota also applies).
# Largest size an uploaded archive may unpack to (default 256 MiB; the storage quota also applies).
#HOST_MAX_UNPACKED_BYTES=268435456
#HOST_MAX_UNPACKED_BYTES=268435456
+# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two
+# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.
+#HOST_MAX_CONCURRENT_UPLOADS=2
# Per-project quota defaults (a staff override per project wins). Production projects:
# Per-project quota defaults (a staff override per project wins). Production projects:
#HOST_QUOTA_STORAGE_BYTES=1073741824
#HOST_QUOTA_STORAGE_BYTES=1073741824
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
# *.openvibe.host before Host Stage B tenant hosting launches: a clear 404 over the wildcard
# *.openvibe.host before Host Stage B tenant hosting launches: a clear 404 over the wildcard
# certificate instead of falling through to the default server (which served another site).
# certificate instead of falling through to the default server (which served another site).
-# Remove when `ovhost nginx tenants host --install` writes the real tenant vhost.
+# `ovhost nginx tenants host --install` removes it in the same nginx -t and reload that installs the
+# real tenant vhost (nginx.tenants.replaces); see docs/launch.md.
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
## REFERENCE copy of `ovhost nginx tenants host` output with no custom domains (regenerate it with
## REFERENCE copy of `ovhost nginx tenants host` output with no custom domains (regenerate it with
-## that command; install with --install, which also writes the custom-domain vhost).
+## that command; install with --install, which also writes the custom-domain vhost and removes
+## openvibe.host-tenants-pending.conf in the same nginx -t and reload).
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
## openvibe.host and *.openvibe.host — generated by `ovhost nginx tenants host`
## openvibe.host and *.openvibe.host — generated by `ovhost nginx tenants host`
##
##
@@ -79,6 +80,21 @@ server {
@@ -79,6 +80,21 @@ server {
}
}
}
}
+# ── www.openvibe.host → openvibe.host ─────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.host;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.host/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ return 301 https://openvibe.host$request_uri;
+}
+
# ── Tenant sites ───────────────────────────────────────────
# ── Tenant sites ───────────────────────────────────────────
server {
server {
listen 443 ssl;
listen 443 ssl;
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"owner": "host",
"owner": "host",
"status": "active",
"status": "active",
"visibility": "first-party",
"visibility": "first-party",
- "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes additionally need a Network staff user.",
+ "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes and takedowns (stop serving a site or a whole project while keeping its content for review; lift) additionally need a Network staff user.",
"permissions": [
"permissions": [
"host:write"
"host:write"
],
],
@@ -26,6 +26,10 @@
@@ -26,6 +26,10 @@
"POST /api/v1/projects/:id/sites",
"POST /api/v1/projects/:id/sites",
"GET /api/v1/sites/:id",
"GET /api/v1/sites/:id",
"DELETE /api/v1/sites/:id",
"DELETE /api/v1/sites/:id",
- "DELETE /api/v1/deploys/:id"
+ "DELETE /api/v1/deploys/:id",
+ "POST /api/v1/projects/:id/takedown",
+ "DELETE /api/v1/projects/:id/takedown",
+ "POST /api/v1/sites/:id/takedown",
+ "DELETE /api/v1/sites/:id/takedown"
]
]
}
}
@@ -226,7 +226,7 @@
@@ -226,7 +226,7 @@
"ready": { "url": "http://127.0.0.1:4910/api/ready", "timeoutSeconds": 30 },
"ready": { "url": "http://127.0.0.1:4910/api/ready", "timeoutSeconds": 30 },
"noRestartPaths": ["docs/", "test/", "**/*.md"],
"noRestartPaths": ["docs/", "test/", "**/*.md"],
"databases": [{ "name": "host", "path": "/var/lib/openvibe-host-api/host.db" }],
"databases": [{ "name": "host", "path": "/var/lib/openvibe-host-api/host.db" }],
- "nginx": { "tenants": { "sitesDomain": "openvibe.host", "dashboardDomain": "openvibe.host", "wildcardCert": "openvibe.host", "database": "/var/lib/openvibe-host-api/host.db", "maxUpload": "110m" } },
+ "nginx": { "tenants": { "sitesDomain": "openvibe.host", "dashboardDomain": "openvibe.host", "wildcardCert": "openvibe.host", "database": "/var/lib/openvibe-host-api/host.db", "maxUpload": "110m", "replaces": ["openvibe.host-tenants-pending.conf"] } },
"drill": {
"drill": {
"port": 14910,
"port": 14910,
"databases": { "host": "HOST_DB_PATH" },
"databases": { "host": "HOST_DB_PATH" },
@@ -206,12 +206,13 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -206,12 +206,13 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
const domains = await lib.nginx.tenantDomains(exec, svc);
const domains = await lib.nginx.tenantDomains(exec, svc);
const r = lib.nginx.renderTenants(inv, svc, { wildcardCert: opts['wildcard-cert'], domains });
const r = lib.nginx.renderTenants(inv, svc, { wildcardCert: opts['wildcard-cert'], domains });
for (const h of r.refused) log(`refused a custom domain value that is not a plain host name: ${JSON.stringify(h)}`);
for (const h of r.refused) log(`refused a custom domain value that is not a plain host name: ${JSON.stringify(h)}`);
- if (json && !opts.install) { emit({ files: r.files.map((f) => f.name), certDir: r.certDir, needCert: r.needCert, refused: r.refused }); return 0; }
+ if (json && !opts.install) { emit({ files: r.files.map((f) => f.name), replaces: r.replaces, certDir: r.certDir, needCert: r.needCert, refused: r.refused }); return 0; }
if (!opts.install) {
if (!opts.install) {
for (const f of r.files) out(`# ---- ${f.name} ----\n${f.text}`);
for (const f of r.files) out(`# ---- ${f.name} ----\n${f.text}`);
+ if (r.replaces.length) out(`# --install also removes, in the same nginx -t and reload: ${r.replaces.join(', ')} (if present)`);
} else {
} else {
- const res = await lib.nginx.install(exec, inv, r.files, { log });
- if (!res.changed.length) log('tenant vhosts already installed; nothing changed');
+ const res = await lib.nginx.install(exec, inv, r.files, { log, remove: r.replaces });
+ if (!res.changed.length && !res.removed.length) log('tenant vhosts already installed; nothing changed');
}
}
for (const h of r.needCert) log(`needs a certificate: certbot certonly --webroot -w /var/www/certbot -d ${h} (then run this again)`);
for (const h of r.needCert) log(`needs a certificate: certbot certonly --webroot -w /var/www/certbot -d ${h} (then run this again)`);
return 0;
return 0;
@@ -93,11 +93,33 @@ async function test(exec, inv) {
@@ -93,11 +93,33 @@ async function test(exec, inv) {
/**
/**
* files: [{ name, text }]. Writes changed files to sites-available, links them into sites-enabled,
* files: [{ name, text }]. Writes changed files to sites-available, links them into sites-enabled,
* runs nginx -t; restores the previous state on failure; reloads nginx on success.
* runs nginx -t; restores the previous state on failure; reloads nginx on success.
- * -> { changed: [names], reloaded }
+ * remove: vhost file names to take out of sites-enabled and sites-available IN THE SAME CHANGE (an
+ * interim vhost the new one replaces); they come back too if nginx -t fails.
+ * -> { changed: [names], removed: [names], reloaded }
*/
*/
-async function install(exec, inv, files, { log = () => {} } = {}) {
+async function install(exec, inv, files, { log = () => {}, remove = [] } = {}) {
const undo = [];
const undo = [];
const changed = [];
const changed = [];
+ const removed = [];
+ for (const name of remove) {
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || files.some((f) => f.name === name)) throw new Error(`cannot remove vhost "${name}"`);
+ const available = path.join(inv.nginx.sitesAvailable, name);
+ const enabled = path.join(inv.nginx.sitesEnabled, name);
+ const prevText = await exec.readFile(available, { privileged: true });
+ // Usually a link to sites-available (possibly dangling); a plain copy is put back as a copy.
+ const target = await exec.readlink(enabled);
+ const link = target || await exec.stat(enabled);
+ if (link) {
+ const copy = target ? null : await exec.readFile(enabled, { privileged: true });
+ await exec.removeFile(enabled, { privileged: true });
+ undo.push(target ? () => exec.symlink(target, enabled, { privileged: true }) : () => exec.writeFile(enabled, copy == null ? '' : copy, { privileged: true, mode: 0o644 }));
+ }
+ if (prevText != null) {
+ await exec.removeFile(available, { privileged: true });
+ undo.push(() => exec.writeFile(available, prevText, { privileged: true, mode: 0o644 }));
+ }
+ if (link || prevText != null) removed.push(name);
+ }
for (const f of files) {
for (const f of files) {
if (!/^[A-Za-z0-9._-]+$/.test(f.name)) throw new Error(`vhost name "${f.name}" is not a file name`);
if (!/^[A-Za-z0-9._-]+$/.test(f.name)) throw new Error(`vhost name "${f.name}" is not a file name`);
const available = path.join(inv.nginx.sitesAvailable, f.name);
const available = path.join(inv.nginx.sitesAvailable, f.name);
@@ -114,7 +136,7 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
@@ -114,7 +136,7 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
if (!changed.includes(f.name)) changed.push(f.name);
if (!changed.includes(f.name)) changed.push(f.name);
}
}
}
}
- if (!changed.length) return { changed, reloaded: false };
+ if (!changed.length && !removed.length) return { changed, removed, reloaded: false };
const t = await test(exec, inv);
const t = await test(exec, inv);
if (!t.ok) {
if (!t.ok) {
for (const u of undo.reverse()) await u();
for (const u of undo.reverse()) await u();
@@ -123,8 +145,8 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
@@ -123,8 +145,8 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
throw err;
throw err;
}
}
await systemd.reloadNginx(exec);
await systemd.reloadNginx(exec);
- log(`nginx: installed ${changed.join(', ')}; nginx -t clean; reloaded`);
- return { changed, reloaded: true };
+ log(`nginx: ${[changed.length && `installed ${changed.join(', ')}`, removed.length && `removed ${removed.join(', ')}`].filter(Boolean).join('; ')}; nginx -t clean; reloaded`);
+ return { changed, removed, reloaded: true };
}
}
// ── Stage B: tenant static hosting (OpenVibe.Host's own service) ─────────────
// ── Stage B: tenant static hosting (OpenVibe.Host's own service) ─────────────
@@ -159,6 +181,10 @@ function tenantsConfig(svc) {
@@ -159,6 +181,10 @@ function tenantsConfig(svc) {
if (/(^|\.)openvibe\.(network|live|media|community|tools)$/.test(sitesDomain)) throw new Error(`tenant sites must not live under ${sitesDomain}: its cookies would reach tenant pages`);
if (/(^|\.)openvibe\.(network|live|media|community|tools)$/.test(sitesDomain)) throw new Error(`tenant sites must not live under ${sitesDomain}: its cookies would reach tenant pages`);
if (!svc.port) throw new Error(`${svc.id} has no port in the inventory`);
if (!svc.port) throw new Error(`${svc.id} has no port in the inventory`);
if (t.maxUpload && !/^\d+[km]?$/.test(String(t.maxUpload))) throw new Error('nginx.tenants.maxUpload must look like 110m');
if (t.maxUpload && !/^\d+[km]?$/.test(String(t.maxUpload))) throw new Error('nginx.tenants.maxUpload must look like 110m');
+ // The interim vhost that answered *.<sitesDomain> with a 404 before launch (deploy/nginx in this
+ // repo). It sorts before <sitesDomain>.conf, so left in place it would keep winning the wildcard.
+ const replaces = t.replaces || [`${sitesDomain}-tenants-pending.conf`];
+ if (!Array.isArray(replaces) || replaces.some((n) => typeof n !== 'string' || !/^[A-Za-z0-9._-]+\.conf$/.test(n))) throw new Error('nginx.tenants.replaces must list vhost file names');
return {
return {
sitesDomain,
sitesDomain,
dashboard,
dashboard,
@@ -168,6 +194,7 @@ function tenantsConfig(svc) {
@@ -168,6 +194,7 @@ function tenantsConfig(svc) {
vhost: t.vhost || `${sitesDomain}.conf`,
vhost: t.vhost || `${sitesDomain}.conf`,
customVhost: t.customVhost || `${sitesDomain}-custom-domains.conf`,
customVhost: t.customVhost || `${sitesDomain}-custom-domains.conf`,
maxUpload: t.maxUpload || '110m',
maxUpload: t.maxUpload || '110m',
+ replaces,
};
};
}
}
@@ -181,8 +208,13 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {
@@ -181,8 +208,13 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {
const zone = `ov${svc.id.replace(/[^a-z0-9]/g, '')}`;
const zone = `ov${svc.id.replace(/[^a-z0-9]/g, '')}`;
const base = { port: svc.port, zone, sitesDomain: t.sitesDomain, service: svc.id };
const base = { port: svc.port, zone, sitesDomain: t.sitesDomain, service: svc.id };
const tenantLocation = fill(readTemplate('tenant-location.tmpl'), base);
const tenantLocation = fill(readTemplate('tenant-location.tmpl'), base);
+ // www.<dashboard> would otherwise be a tenant name (reserved, so "unknown host"): redirect it,
+ // as the placeholder did. Only when the wildcard certificate covers it.
+ const www = `www.${t.dashboard}`;
+ const wwwRedirect = www.endsWith(`.${t.sitesDomain}`) && www.split('.').length === t.sitesDomain.split('.').length + 1
+ ? fill(readTemplate('www-redirect.tmpl'), { www, dashboard: t.dashboard, certDir }) : '';
const main = fill(readTemplate('tenants.conf.tmpl'), {
const main = fill(readTemplate('tenants.conf.tmpl'), {
- ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation,
+ ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation, wwwRedirect,
});
});
const refused = [];
const refused = [];
@@ -211,6 +243,7 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {
@@ -211,6 +243,7 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {
needCert: withoutCert.map((d) => d.hostname),
needCert: withoutCert.map((d) => d.hostname),
refused,
refused,
certDir,
certDir,
+ replaces: t.replaces,
};
};
}
}
@@ -25,6 +25,7 @@ const { openStore } = require('./db');
@@ -25,6 +25,7 @@ const { openStore } = require('./db');
const { createBlobStore } = require('./storage');
const { createBlobStore } = require('./storage');
const { createHostOutbox } = require('./events/outbox');
const { createHostOutbox } = require('./events/outbox');
const { createAccess } = require('./domain/access');
const { createAccess } = require('./domain/access');
+const { createTakedowns } = require('./domain/takedowns');
const { createProjects } = require('./domain/projects');
const { createProjects } = require('./domain/projects');
const { createSites } = require('./domain/sites');
const { createSites } = require('./domain/sites');
const { createDeploys } = require('./domain/deploys');
const { createDeploys } = require('./domain/deploys');
@@ -32,6 +33,7 @@ const { createDomains } = require('./domain/domains');
@@ -32,6 +33,7 @@ const { createDomains } = require('./domain/domains');
const { createAuthClient, createAuthRoutes } = require('./auth/sso');
const { createAuthClient, createAuthRoutes } = require('./auth/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createTenantServer } = require('./http/tenant');
const { createTenantServer } = require('./http/tenant');
+const { createUploadGate } = require('./http/upload');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createDashboard } = require('./http/dashboard');
const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
const { createHostReadiness } = require('./observability');
@@ -71,16 +73,18 @@ function createApp(opts = {}) {
@@ -71,16 +73,18 @@ function createApp(opts = {}) {
const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.fetchImpl, now: store.now, log });
const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.fetchImpl, now: store.now, log });
const access = createAccess({ store });
const access = createAccess({ store });
- const projects = createProjects({ store, config, access, blobs, log });
- const sites = createSites({ store, config, access, projects });
- const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, log });
+ const takedowns = createTakedowns({ store });
+ const projects = createProjects({ store, config, access, blobs, takedowns, log });
+ const sites = createSites({ store, config, access, projects, takedowns });
+ const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
const auth = opts.auth || createAuthClient(config);
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config });
const viewers = createViewerResolver({ auth, config });
- const tenant = createTenantServer({ store, config, blobs, log });
+ const tenant = createTenantServer({ store, config, blobs, takedowns, log });
+ const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
const worker = createWorker({ config, store, domains, blobs, outbox, log });
- const ctx = { config, store, blobs, outbox, access, projects, sites, deploys, domains, auth, viewers, tenant, worker, log };
+ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, auth, viewers, tenant, worker, uploadGate, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -128,7 +132,13 @@ function createApp(opts = {}) {
@@ -128,7 +132,13 @@ function createApp(opts = {}) {
const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release });
const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
app.get('/metrics', sharedMetrics.metricsHandler(registry));
- app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /$\nDisallow: /\n'));
+ // The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
+ // never reach this app's routes; their robots.txt and sitemap are whatever the tenant uploads.
+ const legalPaths = require('openvibe-shared/legal').PATHS;
+ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
+ .send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
+ app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
+ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', ...legalPaths].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`));
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
@@ -16,6 +16,7 @@ const MAX_PATH = 1024;
@@ -16,6 +16,7 @@ const MAX_PATH = 1024;
const MAX_SEGMENT = 255;
const MAX_SEGMENT = 255;
const MAX_DEPTH = 32;
const MAX_DEPTH = 32;
const SEGMENT_RE = /^[A-Za-z0-9._@+~()!,= -]+$/;
const SEGMENT_RE = /^[A-Za-z0-9._@+~()!,= -]+$/;
+const CA_VALIDATION = new Set(['acme-challenge', 'pki-validation']);
class PathError extends Error {
class PathError extends Error {
constructor(code, message) { super(message); this.code = code; }
constructor(code, message) { super(message); this.code = code; }
@@ -42,6 +43,12 @@ function checkPath(input) {
@@ -42,6 +43,12 @@ function checkPath(input) {
}
}
if (s.endsWith(' ') || s.startsWith(' ')) throw new PathError('path.invalid_character', `segment "${s}" starts or ends with a space`);
if (s.endsWith(' ') || s.startsWith(' ')) throw new PathError('path.invalid_character', `segment "${s}" starts or ends with a space`);
});
});
+ // Certificate authorities prove control of a host name with files under these paths (ACME
+ // HTTP-01, and the file-based validation other CAs accept over HTTPS). A tenant must never be
+ // able to obtain a certificate for <site>.openvibe.host: the operator issues every certificate.
+ if (segments[0] === '.well-known' && CA_VALIDATION.has(String(segments[1]).toLowerCase())) {
+ throw new PathError('path.reserved', `"${segments.slice(0, 2).join('/')}/" is reserved: certificate validation files are never published from a site`);
+ }
return input;
return input;
}
}
@@ -42,7 +42,7 @@ function load(env = process.env) {
@@ -42,7 +42,7 @@ function load(env = process.env) {
baseUrl,
baseUrl,
dashboardHost,
dashboardHost,
sitesDomain,
sitesDomain,
- trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1, // nginx (realip) → Node
dbPath: env.HOST_DB_PATH || './data/host.db',
dbPath: env.HOST_DB_PATH || './data/host.db',
// Content-addressed deploy objects, one directory per project (tenancy keyed by project id).
// Content-addressed deploy objects, one directory per project (tenancy keyed by project id).
@@ -74,6 +74,8 @@ function load(env = process.env) {
@@ -74,6 +74,8 @@ function load(env = process.env) {
// unpacked files are held in memory while they are checked, so this bounds what a small
// unpacked files are held in memory while they are checked, so this bounds what a small
// gzip bomb can cost, whatever quota staff grant a project.
// gzip bomb can cost, whatever quota staff grant a project.
maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
+ // Uploads read and validated at the same time, service-wide (each is held in memory).
+ maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),
},
},
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* host_activations every pointer switch (activate / rollback), for audit and rollback targets
* host_activations every pointer switch (activate / rollback), for audit and rollback targets
* host_deploy_logs the upload/validation log of each deploy (there is no build in Stage B)
* host_deploy_logs the upload/validation log of each deploy (there is no build in Stage B)
* host_domains default <site>.openvibe.host and custom domains (TXT-verified)
* host_domains default <site>.openvibe.host and custom domains (TXT-verified)
+ * host_takedowns staff takedowns of a site or a project (serving stops; content is kept)
* event_outbox openvibe-sdk transactional outbox
* event_outbox openvibe-sdk transactional outbox
*
*
* Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's
* Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's
@@ -152,6 +153,21 @@ CREATE UNIQUE INDEX IF NOT EXISTS host_domains_verified ON host_domains (hostnam
@@ -152,6 +153,21 @@ CREATE UNIQUE INDEX IF NOT EXISTS host_domains_verified ON host_domains (hostnam
CREATE UNIQUE INDEX IF NOT EXISTS host_domains_site_host ON host_domains (site_id, hostname);
CREATE UNIQUE INDEX IF NOT EXISTS host_domains_site_host ON host_domains (site_id, hostname);
CREATE INDEX IF NOT EXISTS host_domains_status ON host_domains (kind, status, last_checked_at);
CREATE INDEX IF NOT EXISTS host_domains_status ON host_domains (kind, status, last_checked_at);
+-- Abuse takedowns (staff only): a site, or every site of a project, stops being served (451) while
+-- its deploys, files and objects are KEPT for review. Rows are never deleted: a lift is recorded.
+CREATE TABLE IF NOT EXISTS host_takedowns (
+ id INTEGER PRIMARY KEY,
+ target_kind TEXT NOT NULL CHECK (target_kind IN ('project','site')),
+ target_id TEXT NOT NULL, -- prj_… or site_…
+ reason TEXT NOT NULL,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ lifted_by TEXT,
+ lifted_at INTEGER,
+ lift_note TEXT
+);
+CREATE UNIQUE INDEX IF NOT EXISTS host_takedowns_active ON host_takedowns (target_kind, target_id) WHERE lifted_at IS NULL;
+
-- Deploy artifacts are immutable: only state / deleted_at may change, and never back from 'deleted'.
-- Deploy artifacts are immutable: only state / deleted_at may change, and never back from 'deleted'.
CREATE TRIGGER IF NOT EXISTS host_deploys_immutable
CREATE TRIGGER IF NOT EXISTS host_deploys_immutable
BEFORE UPDATE OF id, project_id, site_id, source, manifest, manifest_sha256, file_count, total_bytes, new_bytes, created_by, created_at ON host_deploys
BEFORE UPDATE OF id, project_id, site_id, source, manifest, manifest_sha256, file_count, total_bytes, new_bytes, created_by, created_at ON host_deploys
@@ -167,7 +183,7 @@ BEFORE DELETE ON host_deploy_files WHEN (SELECT state FROM host_deploys WHERE id
@@ -167,7 +183,7 @@ BEFORE DELETE ON host_deploy_files WHEN (SELECT state FROM host_deploys WHERE id
BEGIN SELECT RAISE(ABORT, 'host: files of a live deploy cannot be removed'); END;
BEGIN SELECT RAISE(ABORT, 'host: files of a live deploy cannot be removed'); END;
`;
`;
-const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains'];
+const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns'];
function openStore(dbPath, { now = () => Date.now() } = {}) {
function openStore(dbPath, { now = () => Date.now() } = {}) {
if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');
@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');
const DAY = 24 * 3600 * 1000;
const DAY = 24 * 3600 * 1000;
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
-function createDeploys({ store, access, projects, sites, blobs, outbox, log = console }) {
+function createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log = console }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
@@ -74,6 +74,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
@@ -74,6 +74,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
/** Before any byte is read: may this caller deploy to this site right now? */
/** Before any byte is read: may this caller deploy to this site right now? */
function precheck(viewer, siteId) {
function precheck(viewer, siteId) {
const { site, project } = sites.load(viewer, siteId, 'deploy');
const { site, project } = sites.load(viewer, siteId, 'deploy');
+ takedowns.assertOpen(site);
const limits = limitsFor(project);
const limits = limitsFor(project);
const recent = q.deploysSince.get(project.id, store.now() - DAY).n;
const recent = q.deploysSince.get(project.id, store.now() - DAY).n;
if (recent >= limits.quota.deploysPerDay) {
if (recent >= limits.quota.deploysPerDay) {
@@ -190,6 +191,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
@@ -190,6 +191,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
function activate(viewer, id, { expectedActive, traceparent } = {}) {
function activate(viewer, id, { expectedActive, traceparent } = {}) {
const { deploy, site } = load(viewer, id, 'deploy');
const { deploy, site } = load(viewer, id, 'deploy');
+ takedowns.assertOpen(site);
const out = store.tx(() => switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));
const out = store.tx(() => switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));
outbox.kick();
outbox.kick();
return out;
return out;
@@ -198,6 +200,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
@@ -198,6 +200,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
/** To `deployId`, or else to the most recent previously active deploy that still exists. */
/** To `deployId`, or else to the most recent previously active deploy that still exists. */
function rollback(viewer, siteId, { deployId, expectedActive, traceparent } = {}) {
function rollback(viewer, siteId, { deployId, expectedActive, traceparent } = {}) {
const { site } = sites.load(viewer, siteId, 'deploy');
const { site } = sites.load(viewer, siteId, 'deploy');
+ takedowns.assertOpen(site);
const out = store.tx(() => {
const out = store.tx(() => {
const current = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;
const current = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;
let target = null;
let target = null;
@@ -236,6 +239,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
@@ -236,6 +239,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
function remove(viewer, id) {
function remove(viewer, id) {
const { deploy, site, project } = load(viewer, id, 'maintain');
const { deploy, site, project } = load(viewer, id, 'maintain');
+ takedowns.assertDeletable(viewer, { site });
let freed = [];
let freed = [];
store.tx(() => {
store.tx(() => {
const active = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;
const active = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;
@@ -18,7 +18,7 @@ const PRINCIPAL_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|app:app_[0-9A-HJKMNP-TV-Z]{26
@@ -18,7 +18,7 @@ const PRINCIPAL_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|app:app_[0-9A-HJKMNP-TV-Z]{26
const QUOTA_FIELDS = { storage_bytes: 'storageBytes', deploys_per_day: 'deploysPerDay', max_files: 'maxFiles', max_file_bytes: 'maxFileBytes', sites: 'sites', custom_domains: 'customDomains' };
const QUOTA_FIELDS = { storage_bytes: 'storageBytes', deploys_per_day: 'deploysPerDay', max_files: 'maxFiles', max_file_bytes: 'maxFileBytes', sites: 'sites', custom_domains: 'customDomains' };
const DAY = 24 * 3600 * 1000;
const DAY = 24 * 3600 * 1000;
-function createProjects({ store, config, access, blobs, log = console }) {
+function createProjects({ store, config, access, blobs, takedowns, log = console }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
byId: db.prepare('SELECT * FROM host_projects WHERE id = ?'),
byId: db.prepare('SELECT * FROM host_projects WHERE id = ?'),
@@ -146,6 +146,7 @@ function createProjects({ store, config, access, blobs, log = console }) {
@@ -146,6 +146,7 @@ function createProjects({ store, config, access, blobs, log = console }) {
/** Owner (or staff) deletes the project: every site stops serving at once; objects are removed. */
/** Owner (or staff) deletes the project: every site stops serving at once; objects are removed. */
function remove(viewer, project, { sites }) {
function remove(viewer, project, { sites }) {
access.authorize(project, viewer, 'own');
access.authorize(project, viewer, 'own');
+ takedowns.assertDeletable(viewer, { projectId: project.id });
const now = store.now();
const now = store.now();
store.tx(() => {
store.tx(() => {
for (const s of sites.listForProject(project.id)) sites.markDeleted(s, now);
for (const s of sites.listForProject(project.id)) sites.markDeleted(s, now);
@@ -21,7 +21,7 @@ const RESERVED = new Set([
@@ -21,7 +21,7 @@ const RESERVED = new Set([
]);
]);
const HOLD_MS = 30 * 24 * 3600 * 1000;
const HOLD_MS = 30 * 24 * 3600 * 1000;
-function createSites({ store, config, access, projects }) {
+function createSites({ store, config, access, projects, takedowns }) {
const { db } = store;
const { db } = store;
const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);
const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);
const q = {
const q = {
@@ -97,6 +97,7 @@ function createSites({ store, config, access, projects }) {
@@ -97,6 +97,7 @@ function createSites({ store, config, access, projects }) {
function remove(viewer, id, { deploys }) {
function remove(viewer, id, { deploys }) {
const { site, project } = load(viewer, id, 'maintain');
const { site, project } = load(viewer, id, 'maintain');
+ takedowns.assertDeletable(viewer, { site });
const now = store.now();
const now = store.now();
let freed = [];
let freed = [];
store.tx(() => {
store.tx(() => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * Abuse takedowns, by OpenVibe staff only (Network role admin).
+ *
+ * A takedown stops a site, or every site of a project, from being served at once: its default and
+ * custom domains answer 451 with no tenant content. Unlike a delete, it KEEPS the deploys, their
+ * file rows, logs and objects, so a report can be reviewed and, where the law requires it, the
+ * material preserved and handed over. While a takedown is active the project's members cannot
+ * upload, activate, roll back or delete anything it covers; staff can still delete. Every takedown
+ * and every lift stays in host_takedowns.
+ *
+ * Members see that their site is taken down, and why (site/project `takedown` in the API).
+ */
+const { ApiError } = require('../http/errors');
+const { principalOf } = require('./access');
+
+const REASON_MAX = 500;
+
+function createTakedowns({ store }) {
+ const { db } = store;
+ const q = {
+ forSite: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns
+ WHERE lifted_at IS NULL AND ((target_kind = 'site' AND target_id = ?) OR (target_kind = 'project' AND target_id = ?))
+ ORDER BY target_kind = 'project' DESC, created_at LIMIT 1`),
+ forProject: db.prepare("SELECT target_kind, reason, created_at FROM host_takedowns WHERE lifted_at IS NULL AND target_kind = 'project' AND target_id = ?"),
+ anyInProject: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns t
+ WHERE lifted_at IS NULL AND ((target_kind = 'project' AND target_id = @p)
+ OR (target_kind = 'site' AND target_id IN (SELECT id FROM host_sites WHERE project_id = @p AND status = 'active')))
+ LIMIT 1`),
+ insert: db.prepare('INSERT INTO host_takedowns (target_kind, target_id, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),
+ lift: db.prepare('UPDATE host_takedowns SET lifted_by = ?, lifted_at = ?, lift_note = ? WHERE target_kind = ? AND target_id = ? AND lifted_at IS NULL'),
+ };
+
+ const shape = (row) => (row ? { scope: row.target_kind, reason: row.reason, since: row.created_at } : null);
+
+ /** The takedown that covers a site (its own, or its project's), or null. */
+ const ofSite = (site) => shape(q.forSite.get(site.id, site.project_id));
+ /** The project-wide takedown, or null. */
+ const ofProject = (projectId) => shape(q.forProject.get(projectId));
+
+ /** Members may not publish into, or change, content that is taken down. */
+ function assertOpen(site) {
+ const t = ofSite(site);
+ if (t) throw new ApiError(403, 'site.taken_down', `this ${t.scope} was taken down by OpenVibe staff (${t.reason}); nothing can be published or changed until staff lift it`);
+ }
+
+ /** Deleting taken-down content would destroy what staff are reviewing: staff only. */
+ function assertDeletable(viewer, { site = null, projectId = null }) {
+ if (viewer && viewer.staff) return;
+ const row = site ? q.forSite.get(site.id, site.project_id) : q.anyInProject.get({ p: projectId });
+ if (row) throw new ApiError(409, 'site.taken_down', `this ${row.target_kind} was taken down by OpenVibe staff and is kept for review; it cannot be deleted until staff lift the takedown`);
+ }
+
+ function requireStaff(viewer) {
+ if (!viewer || !viewer.staff) throw new ApiError(403, 'auth.staff_only', 'only OpenVibe staff can take content down or lift a takedown');
+ }
+
+ /** kind: 'site' | 'project'. -> the takedown */
+ function takeDown(viewer, kind, targetId, reasonRaw) {
+ requireStaff(viewer);
+ const reason = String(reasonRaw || '').trim();
+ if (!reason || reason.length > REASON_MAX) throw new ApiError(422, 'takedown.reason_required', `a takedown needs a reason of 1–${REASON_MAX} characters (members see it)`);
+ try {
+ q.insert.run(kind, targetId, reason, principalOf(viewer), store.now());
+ } catch (err) {
+ if (/UNIQUE/.test(err.message)) throw new ApiError(409, 'takedown.exists', `this ${kind} is already taken down`);
+ throw err;
+ }
+ return { scope: kind, reason, since: store.now() };
+ }
+
+ function lift(viewer, kind, targetId, note) {
+ requireStaff(viewer);
+ const r = q.lift.run(principalOf(viewer), store.now(), note == null ? null : String(note).slice(0, REASON_MAX), kind, targetId);
+ if (r.changes !== 1) throw new ApiError(404, 'takedown.not_found', `this ${kind} is not taken down`);
+ return { lifted: true };
+ }
+
+ return { ofSite, ofProject, assertOpen, assertDeletable, takeDown, lift };
+}
+
+module.exports = { createTakedowns };
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
* DELETE /deploys/:id host.site.manage
* DELETE /deploys/:id host.site.manage
* domains GET/POST /sites/:id/domains · POST /domains/:id/verify
* domains GET/POST /sites/:id/domains · POST /domains/:id/verify
* DELETE /domains/:id host.domain.manage
* DELETE /domains/:id host.domain.manage
+ * takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown host.site.manage (staff)
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
@@ -24,7 +25,7 @@ const out = require('./serialize');
@@ -24,7 +25,7 @@ const out = require('./serialize');
const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());
const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());
function createApi(ctx) {
function createApi(ctx) {
- const { config, projects, sites, deploys, domains, viewers } = ctx;
+ const { config, projects, sites, deploys, domains, viewers, takedowns } = ctx;
const router = express.Router();
const router = express.Router();
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
@@ -47,8 +48,8 @@ function createApi(ctx) {
@@ -47,8 +48,8 @@ function createApi(ctx) {
const u = new URL(config.baseUrl);
const u = new URL(config.baseUrl);
return `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;
return `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;
};
};
- const siteOut = (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname) }); };
- const projectOut = (p, role) => out.project(p, { role, quota: projects.quotaOf(p), usage: projects.usageOf(p) });
+ const siteOut = (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname), takedown: takedowns.ofSite(s) }); };
+ const projectOut = (p, role) => out.project(p, { role, quota: projects.quotaOf(p), usage: projects.usageOf(p), takedown: takedowns.ofProject(p.id) });
const tp = (req) => (req.ov ? req.ov.traceparent : undefined);
const tp = (req) => (req.ov ? req.ov.traceparent : undefined);
const loadProject = (req, need) => {
const loadProject = (req, need) => {
const project = projects.get(req.params.id);
const project = projects.get(req.params.id);
@@ -59,7 +60,7 @@ function createApi(ctx) {
@@ -59,7 +60,7 @@ function createApi(ctx) {
// ── Projects ────────────────────────────────────────────
// ── Projects ────────────────────────────────────────────
router.get('/projects', guard('host.site.manage'), run((req) => {
router.get('/projects', guard('host.site.manage'), run((req) => {
if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
- return { projects: projects.listFor(req.viewer).map((p) => out.project(p)) };
+ return { projects: projects.listFor(req.viewer).map((p) => out.project(p, { takedown: takedowns.ofProject(p.id) })) };
}));
}));
router.post('/projects', guard('host.site.manage'), jsonBody, run((req) => {
router.post('/projects', guard('host.site.manage'), jsonBody, run((req) => {
if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
@@ -94,6 +95,25 @@ function createApi(ctx) {
@@ -94,6 +95,25 @@ function createApi(ctx) {
return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };
return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };
}));
}));
+ // ── Takedowns (staff only; members see them on the project and site) ──
+ // Serving stops at once and the content is kept for review (domain/takedowns.js).
+ router.post('/projects/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
+ const { project } = loadProject(req, 'read');
+ return { takedown: out.takedownOut(takedowns.takeDown(req.viewer, 'project', project.id, (req.body || {}).reason)) };
+ }, 201));
+ router.delete('/projects/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
+ const { project } = loadProject(req, 'read');
+ return takedowns.lift(req.viewer, 'project', project.id, (req.body || {}).note);
+ }));
+ router.post('/sites/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
+ const { site } = sites.load(req.viewer, req.params.id, 'read');
+ return { takedown: out.takedownOut(takedowns.takeDown(req.viewer, 'site', site.id, (req.body || {}).reason)) };
+ }, 201));
+ router.delete('/sites/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
+ const { site } = sites.load(req.viewer, req.params.id, 'read');
+ return takedowns.lift(req.viewer, 'site', site.id, (req.body || {}).note);
+ }));
+
// ── Sites ───────────────────────────────────────────────
// ── Sites ───────────────────────────────────────────────
router.get('/projects/:id/sites', guard('host.site.manage'), run((req) => {
router.get('/projects/:id/sites', guard('host.site.manage'), run((req) => {
const { project } = loadProject(req, 'read');
const { project } = loadProject(req, 'read');
@@ -112,7 +132,8 @@ function createApi(ctx) {
@@ -112,7 +132,8 @@ function createApi(ctx) {
// ── Deploys ─────────────────────────────────────────────
// ── Deploys ─────────────────────────────────────────────
router.get('/sites/:id/deploys', guard('host.deploy.create'), run((req) => {
router.get('/sites/:id/deploys', guard('host.deploy.create'), run((req) => {
- const { site } = sites.load(req.viewer, req.params.id, 'deploy');
+ // Reads need the least member role (deployer); staff may read them too, e.g. to review a takedown.
+ const { site } = sites.load(req.viewer, req.params.id, 'read');
return {
return {
active_deploy_id: site.active_deploy_id || null,
active_deploy_id: site.active_deploy_id || null,
deploys: deploys.list(site.id, Number(req.query.limit) || 50).map((d) => out.deploy(d, { active: d.id === site.active_deploy_id })),
deploys: deploys.list(site.id, Number(req.query.limit) || 50).map((d) => out.deploy(d, { active: d.id === site.active_deploy_id })),
@@ -121,8 +142,11 @@ function createApi(ctx) {
@@ -121,8 +142,11 @@ function createApi(ctx) {
}));
}));
router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {
router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {
+ let release = null;
try {
try {
const pre = deploys.precheck(req.viewer, req.params.id);
const pre = deploys.precheck(req.viewer, req.params.id);
+ release = ctx.uploadGate.enter();
+ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Host is validating other uploads right now; try again in 30 seconds'); }
let up;
let up;
try {
try {
up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
@@ -138,18 +162,20 @@ function createApi(ctx) {
@@ -138,18 +162,20 @@ function createApi(ctx) {
const site = sites.get(pre.site.id);
const site = sites.get(pre.site.id);
res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: Boolean(r.activated && r.activated.changed), url: siteUrl(sites.defaultHostname(site.name)) });
res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: Boolean(r.activated && r.activated.changed), url: siteUrl(sites.defaultHostname(site.name)) });
} catch (err) {
} catch (err) {
- // Refused before the body was read (404, 429): close instead of draining a large upload.
+ // Refused before the body was read (404, 429, 503): close instead of draining a large upload.
if (!req.complete) res.set('Connection', 'close');
if (!req.complete) res.set('Connection', 'close');
if (!res.headersSent) sendError(res, req, err);
if (!res.headersSent) sendError(res, req, err);
+ } finally {
+ if (release) release();
}
}
});
});
router.get('/deploys/:id', guard('host.deploy.create'), run((req) => {
router.get('/deploys/:id', guard('host.deploy.create'), run((req) => {
- const { deploy, site } = deploys.load(req.viewer, req.params.id, 'deploy');
+ const { deploy, site } = deploys.load(req.viewer, req.params.id, 'read');
return { deploy: out.deploy(deploy, { active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id) }) };
return { deploy: out.deploy(deploy, { active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id) }) };
}));
}));
router.get('/deploys/:id/log', guard('host.deploy.create'), run((req) => {
router.get('/deploys/:id/log', guard('host.deploy.create'), run((req) => {
- const { deploy } = deploys.load(req.viewer, req.params.id, 'deploy');
+ const { deploy } = deploys.load(req.viewer, req.params.id, 'read');
return { deploy_id: deploy.id, state: deploy.state, log: deploys.logOf(deploy.id).map(out.logLine) };
return { deploy_id: deploy.id, state: deploy.state, log: deploys.logOf(deploy.id).map(out.logLine) };
}));
}));
router.post('/deploys/:id/activate', guard('host.deploy.create'), jsonBody, run((req) => {
router.post('/deploys/:id/activate', guard('host.deploy.create'), jsonBody, run((req) => {
@@ -25,8 +25,8 @@ function createDashboard(ctx) {
@@ -25,8 +25,8 @@ function createDashboard(ctx) {
router.use(viewers.middleware('dashboard'));
router.use(viewers.middleware('dashboard'));
router.use((req, res, next) => { privateNoStore(res); next(); });
router.use((req, res, next) => { privateNoStore(res); next(); });
- function page(req, res, status, title, body, notice) {
- res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice }));
+ function page(req, res, status, title, body, notice, { indexable = false } = {}) {
+ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable }));
}
}
function fail(req, res, err) {
function fail(req, res, err) {
@@ -56,11 +56,15 @@ function createDashboard(ctx) {
@@ -56,11 +56,15 @@ function createDashboard(ctx) {
const n = req.query.notice;
const n = req.query.notice;
return typeof n === 'string' && n.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: n } : null;
return typeof n === 'string' && n.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: n } : null;
};
};
+ /** A staff takedown outranks any other notice: members must see why nothing is served. */
+ const takedownNotice = (t) => (t ? { kind: 'error', text: `This ${t.scope} was taken down by OpenVibe staff: ${t.reason}. Nothing of it is served, and nothing can be published or deleted, until staff lift the takedown.` } : null);
const back = (path, text, kind) => `${path}?notice=${encodeURIComponent(text)}${kind ? `&kind=${kind}` : ''}`;
const back = (path, text, kind) => `${path}?notice=${encodeURIComponent(text)}${kind ? `&kind=${kind}` : ''}`;
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
router.get('/', view((req, res) => {
router.get('/', view((req, res) => {
- if (req.viewer.kind !== 'user') return page(req, res, 200, null, pages.signedOut());
+ // The signed-out front page is the public page of openvibe.host (sitemap.xml): indexable.
+ // Everything behind sign-in stays noindex and private.
+ if (req.viewer.kind !== 'user') return page(req, res, 200, null, pages.signedOut(), null, { indexable: !req.query || !Object.keys(req.query).length });
page(req, res, 200, 'Projects', pages.home({ projects: projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer) }), notice(req));
page(req, res, 200, 'Projects', pages.home({ projects: projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer) }), notice(req));
}));
}));
@@ -71,7 +75,7 @@ function createDashboard(ctx) {
@@ -71,7 +75,7 @@ function createDashboard(ctx) {
page(req, res, 200, project.name, pages.project({
page(req, res, 200, project.name, pages.project({
project, role, sites: sites.listForProject(project.id), quota: projects.quotaOf(project), usage: projects.usageOf(project),
project, role, sites: sites.listForProject(project.id), quota: projects.quotaOf(project), usage: projects.usageOf(project),
members: projects.members(project), csrf: csrfToken(config, req.viewer), siteUrl,
members: projects.members(project), csrf: csrfToken(config, req.viewer), siteUrl,
- }), notice(req));
+ }), takedownNotice(ctx.takedowns.ofProject(project.id)) || notice(req));
}));
}));
router.get('/sites/:id', view((req, res) => {
router.get('/sites/:id', view((req, res) => {
@@ -81,12 +85,12 @@ function createDashboard(ctx) {
@@ -81,12 +85,12 @@ function createDashboard(ctx) {
site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer),
site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer),
deploys: deploys.list(site.id, 50), activations: deploys.activationsOf(site.id, 10),
deploys: deploys.list(site.id, 50), activations: deploys.activationsOf(site.id, 10),
domains: domains.listForSite(site.id).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),
domains: domains.listForSite(site.id).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),
- }), notice(req));
+ }), takedownNotice(ctx.takedowns.ofSite(site)) || notice(req));
}));
}));
router.get('/deploys/:id', view((req, res) => {
router.get('/deploys/:id', view((req, res) => {
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
- const { deploy, site, project } = deploys.load(req.viewer, req.params.id, 'deploy');
+ const { deploy, site, project } = deploys.load(req.viewer, req.params.id, 'read');
page(req, res, 200, `Deploy ${deploy.id.slice(0, 12)}`, pages.deploy({
page(req, res, 200, `Deploy ${deploy.id.slice(0, 12)}`, pages.deploy({
deploy, site, project, active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id), log: deploys.logOf(deploy.id),
deploy, site, project, active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id), log: deploys.logOf(deploy.id),
}), notice(req));
}), notice(req));
@@ -158,10 +162,13 @@ function createDashboard(ctx) {
@@ -158,10 +162,13 @@ function createDashboard(ctx) {
// Upload: multipart; the form token is checked after the body is parsed (Origin before).
// Upload: multipart; the form token is checked after the body is parsed (Origin before).
router.post('/sites/:id/deploys', async (req, res) => {
router.post('/sites/:id/deploys', async (req, res) => {
+ let release = null;
try {
try {
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this dashboard');
if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this dashboard');
const pre = deploys.precheck(req.viewer, req.params.id);
const pre = deploys.precheck(req.viewer, req.params.id);
+ release = ctx.uploadGate.enter();
+ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Host is validating other uploads right now; try again in 30 seconds'); }
let up;
let up;
try {
try {
up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
@@ -183,6 +190,8 @@ function createDashboard(ctx) {
@@ -183,6 +190,8 @@ function createDashboard(ctx) {
} catch (err) {
} catch (err) {
if (!req.complete) res.set('Connection', 'close');
if (!req.complete) res.set('Connection', 'close');
fail(req, res, err);
fail(req, res, err);
+ } finally {
+ if (release) release();
}
}
});
});
@@ -8,7 +8,12 @@
@@ -8,7 +8,12 @@
*/
*/
const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
-function project(p, { role = null, quota = null, usage = null } = {}) {
+/** A staff takedown as members see it (domain/takedowns.js), or null. */
+function takedownOut(t) {
+ return t ? { scope: t.scope, reason: t.reason, since: iso(t.since) } : null;
+}
+
+function project(p, { role = null, quota = null, usage = null, takedown = null } = {}) {
return {
return {
id: p.id,
id: p.id,
name: p.name,
name: p.name,
@@ -17,6 +22,7 @@ function project(p, { role = null, quota = null, usage = null } = {}) {
@@ -17,6 +22,7 @@ function project(p, { role = null, quota = null, usage = null } = {}) {
owner: p.owner_subject,
owner: p.owner_subject,
role: role || p.role || null,
role: role || p.role || null,
created_at: iso(p.created_at),
created_at: iso(p.created_at),
+ takedown: takedownOut(takedown),
...(quota ? { quota: quotaOut(quota) } : {}),
...(quota ? { quota: quotaOut(quota) } : {}),
...(usage ? { usage: usageOut(usage) } : {}),
...(usage ? { usage: usageOut(usage) } : {}),
};
};
@@ -33,7 +39,7 @@ function usageOut(u) {
@@ -33,7 +39,7 @@ function usageOut(u) {
return { storage_bytes: u.storageBytes, objects: u.objects, deploys_last_24h: u.deploysLast24h, sites: u.sites, custom_domains: u.customDomains };
return { storage_bytes: u.storageBytes, objects: u.objects, deploys_last_24h: u.deploysLast24h, sites: u.sites, custom_domains: u.customDomains };
}
}
-function site(s, { hostname, url }) {
+function site(s, { hostname, url, takedown = null }) {
return {
return {
id: s.id,
id: s.id,
project_id: s.project_id,
project_id: s.project_id,
@@ -41,6 +47,7 @@ function site(s, { hostname, url }) {
@@ -41,6 +47,7 @@ function site(s, { hostname, url }) {
hostname,
hostname,
url,
url,
active_deploy_id: s.active_deploy_id || null,
active_deploy_id: s.active_deploy_id || null,
+ takedown: takedownOut(takedown),
created_at: iso(s.created_at),
created_at: iso(s.created_at),
updated_at: iso(s.updated_at),
updated_at: iso(s.updated_at),
};
};
@@ -89,4 +96,4 @@ function activation(a) {
@@ -89,4 +96,4 @@ function activation(a) {
return { deploy_id: a.deploy_id, previous_deploy_id: a.previous_deploy_id || null, kind: a.kind, actor: a.actor, at: iso(a.created_at) };
return { deploy_id: a.deploy_id, previous_deploy_id: a.previous_deploy_id || null, kind: a.kind, actor: a.actor, at: iso(a.created_at) };
}
}
-module.exports = { project, quotaOut, usageOut, site, deploy, logLine, domain, activation, iso };
+module.exports = { project, takedownOut, quotaOut, usageOut, site, deploy, logLine, domain, activation, iso };
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* Responses: GET/HEAD only; Content-Type from the manifest; strong ETag (the sha256); 304 on
* Responses: GET/HEAD only; Content-Type from the manifest; strong ETag (the sha256); 304 on
* If-None-Match; single byte ranges; `immutable` caching for fingerprinted assets and revalidation
* If-None-Match; single byte ranges; `immutable` caching for fingerprinted assets and revalidation
* for everything else (so a rollback shows at once); nosniff; a strict CSP; never a cookie.
* for everything else (so a rollback shows at once); nosniff; a strict CSP; never a cookie.
+ * A site that staff took down (domain/takedowns.js) answers 451 with none of its content.
*/
*/
const fs = require('fs');
const fs = require('fs');
const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
@@ -74,7 +75,7 @@ function plain(res, status, text, extra = {}) {
@@ -74,7 +75,7 @@ function plain(res, status, text, extra = {}) {
res.end(text);
res.end(text);
}
}
-function createTenantServer({ store, config, blobs, log = console }) {
+function createTenantServer({ store, config, blobs, takedowns = null, log = console }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
siteByName: db.prepare("SELECT s.*, p.environment FROM host_sites s JOIN host_projects p ON p.id = s.project_id WHERE s.name = ? AND s.status = 'active' AND p.status = 'active'"),
siteByName: db.prepare("SELECT s.*, p.environment FROM host_sites s JOIN host_projects p ON p.id = s.project_id WHERE s.name = ? AND s.status = 'active' AND p.status = 'active'"),
@@ -178,6 +179,10 @@ function createTenantServer({ store, config, blobs, log = console }) {
@@ -178,6 +179,10 @@ function createTenantServer({ store, config, blobs, log = console }) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
}
}
+ // Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
+ if (takedowns && takedowns.ofSite(site)) {
+ return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.');
+ }
// Absolute-form request targets must agree with the Host header (or they would pick the site).
// Absolute-form request targets must agree with the Host header (or they would pick the site).
let target = req.url || '/';
let target = req.url || '/';
if (!target.startsWith('/')) {
if (!target.startsWith('/')) {
@@ -196,8 +201,12 @@ function createTenantServer({ store, config, blobs, log = console }) {
@@ -196,8 +201,12 @@ function createTenantServer({ store, config, blobs, log = console }) {
for (const p of c.list) {
for (const p of c.list) {
const row = q.file.get(deployId, p);
const row = q.file.get(deployId, p);
if (row && row.project_id === site.project_id) {
if (row && row.project_id === site.project_id) {
- const cache = isHashedAsset(p) ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate';
- return send(req, res, site, row, { cache });
+ const hashed = isHashedAsset(p);
+ // Browsers keep fingerprinted assets for a year; a shared CDN in front of the tenant
+ // vhost (Cloudflare) keeps them for an hour at most, so a takedown, deletion or
+ // rollback leaves the edge within the hour even if nobody purges it.
+ if (hashed) res.setHeader('CDN-Cache-Control', 'public, max-age=3600');
+ return send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });
}
}
}
}
if (!c.dir && c.dirIndex && q.file.get(deployId, c.dirIndex)) {
if (!c.dir && c.dirIndex && q.file.get(deployId, c.dirIndex)) {
@@ -172,4 +172,25 @@ async function readUpload(req, { maxUploadBytes, maxUnpackedBytes = Infinity, li
@@ -172,4 +172,25 @@ async function readUpload(req, { maxUploadBytes, maxUnpackedBytes = Infinity, li
return { source, entries, fields, notes };
return { source, entries, fields, notes };
}
}
-module.exports = { readUpload, UploadError };
+/**
+ * At most `max` uploads are read and validated at once, service-wide. An upload is held in memory
+ * (up to HOST_MAX_UPLOAD_BYTES, plus up to HOST_MAX_UNPACKED_BYTES once unpacked), so without a cap
+ * a handful of accounts uploading together could exhaust the host's memory. enter() -> release()
+ * or null when every slot is taken (the caller answers 503 with Retry-After before reading a byte).
+ */
+function createUploadGate(max) {
+ const limit = Math.max(1, Number(max) || 1);
+ let inFlight = 0;
+ return {
+ enter() {
+ if (inFlight >= limit) return null;
+ inFlight++;
+ let released = false;
+ return () => { if (!released) { released = true; inFlight--; } };
+ },
+ get inFlight() { return inFlight; },
+ limit,
+ };
+}
+
+module.exports = { readUpload, UploadError, createUploadGate };
@@ -53,6 +53,7 @@ function renderPage(o) {
@@ -53,6 +53,7 @@ function renderPage(o) {
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME)}</title>
<title>${esc(o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME)}</title>
<meta name="robots" content="${o.indexable ? 'index, follow' : 'noindex, nofollow'}">
<meta name="robots" content="${o.indexable ? 'index, follow' : 'noindex, nofollow'}">
+${o.indexable ? `<link rel="canonical" href="${esc(`${o.config.baseUrl}${o.path && o.path.split('?')[0] !== '/' ? o.path.split('?')[0] : '/'}`)}">` : ''}
<meta name="description" content="OpenVibe.Host: static site hosting for OpenVibe projects (alpha).">
<meta name="description" content="OpenVibe.Host: static site hosting for OpenVibe projects (alpha).">
${appIcon.headTags({ site: 'host' })}
${appIcon.headTags({ site: 'host' })}
<link rel="stylesheet" href="${asset('css/host.css')}">
<link rel="stylesheet" href="${asset('css/host.css')}">
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
proxy_set_header Connection "";
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Last-Event-ID $http_last_event_id;
proxy_set_header Last-Event-ID $http_last_event_id;
proxy_buffering off;
proxy_buffering off;
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
proxy_set_header Connection ${{zone}}_connection;
proxy_set_header Connection ${{zone}}_connection;
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_buffering off;
proxy_read_timeout 1h;
proxy_read_timeout 1h;
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
proxy_set_header Cookie "";
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_set_header Authorization "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header Set-Cookie;
proxy_hide_header Set-Cookie;
proxy_read_timeout 60s;
proxy_read_timeout 60s;
@@ -44,7 +44,8 @@ server {
@@ -44,7 +44,8 @@ server {
proxy_set_header Connection "";
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
location /auth/ {
location /auth/ {
@@ -76,7 +77,7 @@ server {
@@ -76,7 +77,7 @@ server {
}
}
}
}
-# ── Tenant sites ───────────────────────────────────────────
+{{wwwRedirect}}# ── Tenant sites ───────────────────────────────────────────
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
@@ -46,7 +46,8 @@ server {
@@ -46,7 +46,8 @@ server {
proxy_set_header Connection "";
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_read_timeout 60s;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+# ── {{www}} → {{dashboard}} ─────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name {{www}};
+
+ ssl_certificate {{certDir}}/fullchain.pem;
+ ssl_certificate_key {{certDir}}/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ return 301 https://{{dashboard}}$request_uri;
+}
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+'use strict';
+/**
+ * Abuse controls (docs/threat-review.md): staff takedowns stop serving at once and keep the content
+ * for review; members cannot publish, switch or delete around a takedown; certificate-validation
+ * paths cannot be published; uploads are validated a few at a time, so concurrent uploads cannot
+ * exhaust memory; fingerprinted assets leave a shared CDN within the hour.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { boot, check, done } = require('./stageb/boot');
+const { site: siteTar } = require('./stageb/tar');
+
+(async () => {
+ const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1' } });
+ const alice = t.user('alice');
+ const staff = t.user('root', { role: 'admin' });
+ const pa = await t.project(alice, 'A');
+ const alpha = await t.site(alice, pa.id, 'alpha');
+ const other = await t.site(alice, pa.id, 'other');
+ const d1 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA ONE', 'app.3f2a9c1b.js': 'js();' });
+ const d2 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA TWO' });
+ await t.deploy(alice, other.id, { 'index.html': 'OTHER' });
+ const dom = await t.api('POST', `/api/v1/sites/${alpha.id}/domains`, { as: alice, json: { hostname: 'www.alpha-example.org' } });
+ const domain = dom.json().domain;
+ t.dns.set("_openvibe-host.www.alpha-example.org", [domain.instructions.verification.value]);
+ assert.strictEqual((await t.api('POST', `/api/v1/domains/${domain.id}/verify`, { as: alice })).json().domain.status, 'verified');
+ const objectsOf = (p) => { const out = []; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(path.join(d, e.name)); else out.push(e.name); } }; walk(path.join(t.dir, 'objects', 'projects', p)); return out.sort(); };
+
+ await check('fingerprinted assets: immutable for browsers, at most an hour at a shared CDN', async () => {
+ const r = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');
+ await t.api('POST', `/api/v1/deploys/${d1.id}/activate`, { as: alice });
+ const r1 = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');
+ assert.strictEqual(r.status, 404, 'not in the active deploy yet');
+ assert.strictEqual(r1.headers['cache-control'], 'public, max-age=31536000, immutable');
+ assert.strictEqual(r1.headers['cdn-cache-control'], 'public, max-age=3600');
+ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).headers['cdn-cache-control'], undefined);
+ await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice });
+ });
+
+ await check('only staff take a site down; members and strangers cannot', async () => {
+ const mine = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: alice, json: { reason: 'self' } });
+ assert.strictEqual(mine.status, 403);
+ assert.strictEqual(mine.json().code, 'auth.staff_only');
+ const stranger = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: t.user('mallory'), json: { reason: 'x' } });
+ assert.strictEqual(stranger.status, 404, 'a stranger learns nothing');
+ const noReason = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: {} });
+ assert.strictEqual(noReason.status, 422);
+ });
+
+ const objectsBefore = objectsOf(pa.id);
+ await check('a takedown stops the default and custom domains at once (451, no tenant bytes) and keeps everything', async () => {
+ const r = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'phishing report #42' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().takedown.reason, 'phishing report #42');
+ for (const host of ['alpha.openvibe.host', 'www.alpha-example.org']) {
+ for (const p of ['/', '/index.html', '/app.3f2a9c1b.js', '/404.html', '/missing']) {
+ const g = await t.get(host, p);
+ assert.strictEqual(g.status, 451, `${host}${p}`);
+ assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');
+ assert.strictEqual(g.headers['cache-control'], 'no-store');
+ assert.strictEqual(g.headers.etag, undefined);
+ }
+ }
+ assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');
+ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore, 'every object kept');
+ const files = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: staff });
+ assert.strictEqual(files.json().deploy.files.length, 2, 'file rows kept for review');
+ assert.strictEqual((await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'again' } })).status, 409);
+ });
+
+ await check('members see the takedown and why, and cannot publish, switch or delete around it', async () => {
+ const s = await t.api('GET', `/api/v1/sites/${alpha.id}`, { as: alice });
+ assert.deepStrictEqual([s.json().site.takedown.scope, s.json().site.takedown.reason], ['site', 'phishing report #42']);
+ const up = await t.upload(alice, alpha.id, { 'index.html': 'EVADE' });
+ assert.strictEqual(up.status, 403);
+ assert.strictEqual(up.json().code, 'site.taken_down');
+ for (const [method, p, json] of [
+ ['POST', `/api/v1/deploys/${d1.id}/activate`, {}], ['POST', `/api/v1/sites/${alpha.id}/rollback`, {}],
+ ['DELETE', `/api/v1/deploys/${d1.id}`], ['DELETE', `/api/v1/sites/${alpha.id}`], ['DELETE', `/api/v1/projects/${pa.id}`],
+ ]) {
+ const r = await t.api(method, p, { as: alice, json });
+ assert.ok([403, 409].includes(r.status), `${method} ${p} → ${r.status} ${r.text}`);
+ assert.strictEqual(r.json().code, 'site.taken_down');
+ }
+ const page = await t.api('GET', `/sites/${alpha.id}`, { session: alice });
+ assert.match(page.text, /taken down by OpenVibe staff: phishing report #42/);
+ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore);
+ });
+
+ await check('staff lift a takedown: the same content serves again; members only lift nothing', async () => {
+ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: alice })).status, 403);
+ const r = await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { note: 'reviewed: false positive' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).text, 'ALPHA TWO');
+ assert.strictEqual((await t.get('www.alpha-example.org', '/')).text, 'ALPHA TWO');
+ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 404);
+ const rows = t.ctx.store.db.prepare('SELECT target_kind, reason, lifted_at IS NOT NULL AS lifted, lift_note FROM host_takedowns').all();
+ assert.deepStrictEqual(rows, [{ target_kind: 'site', reason: 'phishing report #42', lifted: 1, lift_note: 'reviewed: false positive' }], 'the history is kept');
+ });
+
+ await check('a project takedown covers every site of the project; staff can still delete', async () => {
+ const r = await t.api('POST', `/api/v1/projects/${pa.id}/takedown`, { as: staff, json: { reason: 'spam network' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).status, 451);
+ assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 451);
+ const p = await t.api('GET', `/api/v1/projects/${pa.id}`, { as: alice });
+ assert.strictEqual(p.json().project.takedown.scope, 'project');
+ assert.strictEqual(p.json().sites[0].takedown.scope, 'project');
+ const list = await t.api('GET', '/api/v1/projects', { as: alice });
+ assert.strictEqual(list.json().projects[0].takedown.reason, 'spam network');
+ assert.strictEqual((await t.upload(alice, other.id, { 'index.html': 'x' })).status, 403);
+ const del = await t.api('DELETE', `/api/v1/sites/${other.id}`, { as: staff });
+ assert.strictEqual(del.status, 200, del.text);
+ assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 404);
+ });
+
+ await check('certificate-validation paths are never published (no tenant can get a certificate for its host name)', async () => {
+ const pb = await t.project(alice, 'B');
+ const beta = await t.site(alice, pb.id, 'beta');
+ for (const p of ['.well-known/acme-challenge/token123', '.well-known/pki-validation/fileauth.txt', '.well-known/ACME-CHALLENGE/x']) {
+ const r = await t.upload(alice, beta.id, { 'index.html': 'B', [p]: 'proof' });
+ assert.strictEqual(r.status, 422, `${p} → ${r.status}`);
+ assert.ok(r.json().log.some((l) => /reserved/.test(l)), r.text);
+ }
+ await t.deploy(alice, beta.id, { 'index.html': 'B', '.well-known/security.txt': 'Contact: mailto:[email protected]' });
+ assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/security.txt')).status, 200, 'other .well-known files still publish');
+ assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/acme-challenge/token123')).status, 404);
+ });
+
+ await check('uploads are validated a few at a time: a second concurrent upload gets 503 + Retry-After and is not a failed deploy', async () => {
+ const pc = await t.project(alice, 'C');
+ const gamma = await t.site(alice, pc.id, 'gamma');
+ const body = siteTar({ 'index.html': 'GAMMA' });
+ const failedBefore = t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get().n;
+ // The first upload sends its headers and half its body, then waits.
+ let firstResponse;
+ let finish;
+ const first = new Promise((resolve) => {
+ const req = http.request({ host: '127.0.0.1', port: t.port, method: 'POST', path: `/api/v1/sites/${gamma.id}/deploys?activate=1`, headers: { host: 'openvibe.host', authorization: `Bearer ${t.network.userToken(alice)}`, 'content-type': 'application/gzip', 'content-length': body.length } }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => { firstResponse = { status: res.statusCode, text: Buffer.concat(chunks).toString() }; resolve(); });
+ });
+ req.write(body.subarray(0, Math.floor(body.length / 2)));
+ finish = () => req.end(body.subarray(Math.floor(body.length / 2)));
+ });
+ await new Promise((r) => setTimeout(r, 150));
+ assert.strictEqual(t.ctx.uploadGate.inFlight, 1);
+ const second = await t.upload(alice, gamma.id, { 'index.html': 'SECOND' });
+ assert.strictEqual(second.status, 503, second.text);
+ assert.strictEqual(second.json().code, 'upload.busy');
+ assert.strictEqual(second.headers['retry-after'], '30');
+ finish();
+ await first;
+ assert.strictEqual(firstResponse.status, 201, firstResponse.text);
+ assert.strictEqual(t.ctx.uploadGate.inFlight, 0, 'the slot is released');
+ assert.strictEqual(t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get().n, failedBefore, 'a busy refusal is not the tenant\'s failed deploy');
+ const third = await t.upload(alice, gamma.id, { 'index.html': 'THIRD' });
+ assert.strictEqual(third.status, 201, third.text);
+ // A refused upload releases its slot too.
+ const bad = await t.upload(alice, gamma.id, { '../x.html': 'no' });
+ assert.strictEqual(bad.status, 422);
+ assert.strictEqual(t.ctx.uploadGate.inFlight, 0);
+ });
+
+ await t.close();
+ done();
+})();
@@ -1,8 +1,10 @@
@@ -1,8 +1,10 @@
'use strict';
'use strict';
/**
/**
* Wave 21 exit criterion: "a hosted static project cannot read another tenant's objects".
* Wave 21 exit criterion: "a hosted static project cannot read another tenant's objects".
- * Path tricks, encoded traversal, Host header tricks, absolute-form targets, shared content, and
- * the API: a tenant only ever reaches its own site, deploys and domains.
+ * Path tricks, encoded traversal, Host header tricks, absolute-form targets, shared content, the API,
+ * cross-origin reads from a tenant page (no CORS grant anywhere), existence oracles (ETag, Range),
+ * delegated service tokens, app principals and the dashboard: a tenant only ever reaches its own
+ * site, deploys and domains.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -135,6 +137,65 @@ const { boot, check, done } = require('./stageb/boot');
@@ -135,6 +137,65 @@ const { boot, check, done } = require('./stageb/boot');
assert.strictEqual(r.headers['content-type'], 'application/problem+json');
assert.strictEqual(r.headers['content-type'], 'application/problem+json');
});
});
+ await check('a tenant page\'s scripts cannot read another tenant\'s files: no CORS grant on tenant responses, preflights refused', async () => {
+ for (const origin of ['https://alpha.openvibe.host', 'null', 'https://evil.example']) {
+ const r = await t.get('beta.openvibe.host', '/secret.html', { headers: { origin } });
+ assert.strictEqual(r.status, 200);
+ for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith('access-control-'), `beta answered ${origin} with ${h}`);
+ const pre = await t.request({ method: 'OPTIONS', host: 'beta.openvibe.host', path: '/secret.html', headers: { origin, 'access-control-request-method': 'GET' } });
+ assert.strictEqual(pre.status, 405);
+ assert.ok(!Object.keys(pre.headers).some((h) => h.startsWith('access-control-')));
+ }
+ assert.strictEqual((await t.get('beta.openvibe.host', '/secret.html')).headers['cross-origin-opener-policy'], 'same-origin');
+ });
+
+ await check('the Host API grants CORS to no tenant origin (a tenant page cannot call it from a visitor\'s browser)', async () => {
+ for (const origin of ['https://alpha.openvibe.host', 'https://beta.openvibe.host', 'https://openvibe.host.evil.example']) {
+ const r = await t.api('GET', '/api/v1/projects', { as: bob, headers: { origin } });
+ assert.strictEqual(r.headers['access-control-allow-origin'], undefined, origin);
+ const pre = await t.request({ method: 'OPTIONS', host: 'openvibe.host', path: '/api/v1/projects', headers: { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
+ assert.strictEqual(pre.headers['access-control-allow-origin'], undefined, `preflight from ${origin}`);
+ }
+ });
+
+ await check('no existence oracle: another tenant\'s sha256 as If-None-Match or a Range on alpha is still a 404', async () => {
+ const r = await t.get('alpha.openvibe.host', '/secret.html', { headers: { 'if-none-match': `"${secretSha}"` } });
+ assert.strictEqual(r.status, 404);
+ const r2 = await t.get('alpha.openvibe.host', `/${secretSha}`, { headers: { 'if-none-match': `"${secretSha}"`, range: 'bytes=0-3' } });
+ assert.strictEqual(r2.status, 404);
+ assert.ok(!leaks(r) && !leaks(r2));
+ });
+
+ await check('delegation and app principals stay inside their own project', async () => {
+ const codes = t.network.serviceToken('codes', ['host.site.manage', 'host.deploy.create', 'host.domain.manage']);
+ for (const p of [`/api/v1/projects/${pb.id}`, `/api/v1/deploys/${db.id}`, `/api/v1/deploys/${db.id}/log`, `/api/v1/sites/${beta.id}/deploys`]) {
+ const r = await t.api('GET', p, { as: codes, headers: { 'x-ov-subject': alice.subject } });
+ assert.strictEqual(r.status, 404, `svc:codes for alice → ${p} ${r.status}`);
+ assert.ok(!leaks(r) && !r.text.includes(secretSha));
+ }
+ const ci = t.network.appToken('app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', ['host.deploy.create', 'host.site.manage']);
+ const add = await t.api('PUT', `/api/v1/projects/${pa.id}/members/app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3`, { as: alice, json: { role: 'deployer' } });
+ assert.strictEqual(add.status, 200, add.text);
+ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/deploys`, { as: ci })).status, 200, 'the app reads its own project');
+ for (const p of [`/api/v1/deploys/${db.id}`, `/api/v1/sites/${beta.id}/deploys`]) assert.strictEqual((await t.api('GET', p, { as: ci })).status, 404, p);
+ const up = await t.api('POST', `/api/v1/sites/${beta.id}/deploys?activate=1`, { as: ci, body: require('./stageb/tar').site({ 'index.html': 'PWNED' }), headers: { 'content-type': 'application/gzip' } });
+ assert.strictEqual(up.status, 404);
+ // X-OV-Subject is honoured for first-party services only: an app cannot borrow bob's identity.
+ const borrow = await t.api('GET', `/api/v1/deploys/${db.id}`, { as: ci, headers: { 'x-ov-subject': bob.subject } });
+ assert.strictEqual(borrow.status, 400);
+ assert.strictEqual(borrow.json().code, 'subject.not_delegable');
+ assert.ok(!leaks(borrow));
+ assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');
+ });
+
+ await check('the dashboard (cookie session) shows a non-member nothing of another tenant: no names, files or hashes', async () => {
+ for (const p of [`/projects/${pb.id}`, `/sites/${beta.id}`, `/deploys/${db.id}`]) {
+ const r = await t.api('GET', p, { session: alice });
+ assert.strictEqual(r.status, 404, `${p} → ${r.status}`);
+ assert.ok(!/secret\.html|BETA|beta-example/.test(r.text) && !r.text.includes(secretSha), `${p} leaked`);
+ }
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();
@@ -57,7 +57,26 @@ const ORIGIN = 'https://openvibe.host';
@@ -57,7 +57,26 @@ const ORIGIN = 'https://openvibe.host';
assert.match(r.headers['content-security-policy'], /frame-ancestors 'none'/);
assert.match(r.headers['content-security-policy'], /frame-ancestors 'none'/);
assert.strictEqual(r.headers['x-frame-options'], 'DENY');
assert.strictEqual(r.headers['x-frame-options'], 'DENY');
assert.strictEqual(r.headers['cache-control'], 'private, no-store');
assert.strictEqual(r.headers['cache-control'], 'private, no-store');
- assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);
+ // The public front page is indexable, with a canonical URL; nothing behind sign-in is.
+ assert.match(r.text, /<meta name="robots" content="index, follow">/);
+ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.host\/">/);
+ const signedIn = await t.api('GET', '/', { session: alice });
+ assert.match(signedIn.text, /<meta name="robots" content="noindex, nofollow">/);
+ assert.ok(!/rel="canonical"/.test(signedIn.text));
+ const withQuery = await t.api('GET', '/?notice=hi');
+ assert.match(withQuery.text, /<meta name="robots" content="noindex, nofollow">/);
+ });
+
+ await check('robots.txt and sitemap.xml: the front page and the legal pages only; tenant sites keep their own', async () => {
+ const robots = await t.api('GET', '/robots.txt');
+ assert.strictEqual(robots.status, 200);
+ assert.strictEqual(robots.text, 'User-agent: *\nAllow: /$\nAllow: /terms$\nAllow: /privacy$\nAllow: /dmca$\nDisallow: /\n\nSitemap: https://openvibe.host/sitemap.xml\n');
+ const map = await t.api('GET', '/sitemap.xml');
+ assert.strictEqual(map.status, 200);
+ assert.match(map.headers['content-type'], /application\/xml/);
+ const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ assert.deepStrictEqual(locs, ['https://openvibe.host/', 'https://openvibe.host/terms', 'https://openvibe.host/privacy', 'https://openvibe.host/dmca']);
+ for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
});
});
await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {
await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {
@@ -84,6 +84,64 @@ runTests([
@@ -84,6 +84,64 @@ runTests([
assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null);
assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null);
}),
}),
+ test('launch: --install removes the interim tenants-pending vhost in the same nginx -t and reload; a failed test brings it back', async () => {
+ const PENDING = '# *.openvibe.host before Host Stage B tenant hosting launches\nserver { server_name *.openvibe.host; return 404; }\n';
+ const SITES = '# openvibe.host — static front page (OpenVibe.Sites)\nserver { server_name openvibe.host; root /opt/openvibe.sites/dist/openvibe.host; }\n';
+ const prepare = (host) => {
+ host.put('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', PENDING);
+ host.files.set('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', owner: 'root', mode: 0o777 });
+ host.put('/etc/nginx/sites-available/openvibe.host.conf', SITES);
+ host.files.set('/etc/nginx/sites-enabled/openvibe.host.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host.conf', owner: 'root', mode: 0o777 });
+ return host;
+ };
+ const review = await prepare(withHost(scenario(), [])).cli('nginx', 'tenants', 'host');
+ assert.match(review.out, /--install also removes, in the same nginx -t and reload: openvibe\.host-tenants-pending\.conf/);
+
+ const host = prepare(withHost(scenario(), []));
+ let seenAtTest = null;
+ host.nginxTest = () => {
+ seenAtTest = { pending: host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'), main: host.read('/etc/nginx/sites-available/openvibe.host.conf') };
+ return { code: 0, stderr: 'ok' };
+ };
+ const r = await host.cli('nginx', 'tenants', 'host', '--install');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.deepStrictEqual([seenAtTest.pending, /server_name \*\.openvibe\.host;/.test(seenAtTest.main)], [false, true], 'one nginx -t sees the tenant vhost and no pending vhost');
+ assert.ok(!host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'));
+ assert.ok(!host.files.has('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'));
+ assert.match(r.out, /removed openvibe\.host-tenants-pending\.conf/);
+ assert.strictEqual(host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length, 1, 'one reload');
+ const again = await host.cli('nginx', 'tenants', 'host', '--install');
+ assert.match(again.out, /already installed; nothing changed/);
+
+ const bad = prepare(withHost(scenario(), []));
+ bad.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] cannot load certificate' });
+ const f = await bad.cli('nginx', 'tenants', 'host', '--install');
+ assert.strictEqual(f.code, 2);
+ assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'), PENDING, 'pending vhost restored');
+ assert.strictEqual(bad.files.get('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf').target, '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf');
+ assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host.conf'), SITES, 'the Sites placeholder vhost restored');
+ assert.ok(!bad.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'not reloaded');
+ }),
+
+ test('client address headers come from $remote_addr only (realip), in every template ovhost renders', async () => {
+ const fs = require('fs');
+ const path = require('path');
+ const dir = path.join(__dirname, '..', 'templates', 'nginx');
+ for (const f of fs.readdirSync(dir)) {
+ const t = fs.readFileSync(path.join(dir, f), 'utf8');
+ assert.ok(!/\$proxy_add_x_forwarded_for|\$http_x_forwarded_for|\$http_cf_connecting_ip/.test(t), `${f} passes a client-supplied address on`);
+ if (/X-Forwarded-For/.test(t)) assert.match(t, /X-Forwarded-For \$remote_addr;/, f);
+ }
+ const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');
+ assert.ok(!/proxy_add_x_forwarded_for/.test(r.out));
+ assert.match(r.out, /proxy_set_header CF-Connecting-IP \$remote_addr;/);
+ }),
+
+ test('www.openvibe.host redirects to the dashboard over the wildcard certificate', async () => {
+ const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');
+ assert.match(r.out, /server_name www\.openvibe\.host;\n\n ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.host\/fullchain\.pem;[\s\S]*?return 301 https:\/\/openvibe\.host\$request_uri;/);
+ }),
+
test('refuses a service without a tenants block, a bad certificate path, and sites under a first-party domain', async () => {
test('refuses a service without a tenants block, a bad certificate path, and sites under a first-party domain', async () => {
const host = withHost(scenario(), []);
const host = withHost(scenario(), []);
assert.notStrictEqual((await host.cli('nginx', 'tenants', 'media')).code, 0);
assert.notStrictEqual((await host.cli('nginx', 'tenants', 'media')).code, 0);