openvibers/openvibe.mediahub Commits
Serve /.well-known/security.txt (RFC 9116) on every host (#181)
Changes
@@@ -12,6 +12,7 @@ const path = require('path');const seo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');++const securityTxt = require('./security-txt');const SITE_NAME = 'OpenVibe.Download';const DESCRIPTION = 'A private drive on your OpenVibe account: upload files, share them with links you can take back, and back up a folder from your computer.';@@@ -114,6 +115,9 @@ function createDiscoveryRoutes(ctx) {res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));});++ // RFC 9116: the host's own canonical URL, this service's drive host.++ r.get('/.well-known/security.txt', securityTxt.handler(site));++return r;}nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++++/**++ * /.well-known/security.txt (RFC 9116, SEO-30) for every host this service answers for — openvibe.download and its++ * pages, openvibe.video and openvibe.pics. Contact, Expires and Canonical are required; the canonical URL is the++ * host's own, so the same helper serves all three without a hard-coded host.++ */++++// The date the file must be refreshed by: one year on, a person re-reads and re-commits it.++const EXPIRES = '2027-10-29T00:00:00.000Z';++const CONTACT = 'mailto:[email protected]';++// Listed only while https://openvibe.network/security answers 200 (checked 2026-10-29).++const POLICY = 'https://openvibe.network/security';++++function securityTxt(origin) {++ const site = String(origin).replace(/\/+$/, '');++ return [++ `Contact: ${CONTACT}`,++ `Expires: ${EXPIRES}`,++ 'Preferred-Languages: en',++ `Canonical: ${site}/.well-known/security.txt`,++ `Policy: ${POLICY}`,++ '',++ ].join('\n');++}++++/** The route itself: text/plain, a day's cache. */++function handler(origin) {++ const body = securityTxt(origin);++ return (_req, res) => res.type('text/plain; charset=utf-8').set('Cache-Control', 'public, max-age=86400').send(body);++}++++module.exports = { securityTxt, handler, EXPIRES, CONTACT, POLICY };@@@ -23,6 +23,7 @@ const fs = require('fs');const os = require('os');const path = require('path');const cache = require('openvibe-shared/cache-policy');++const securityTxt = require('../http/security-txt');const seo = require('openvibe-shared/seo');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');@@@ -577,6 +578,8 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res// The indexable pages: the home, the update log and the rules. No picture or album is ever listed.r.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(['/', '/updates', ...legal.PATHS].map((p) => ({ loc: `${base}${p}` })))));++ // RFC 9116, canonical to this host (openvibe.pics).++ r.get('/.well-known/security.txt', securityTxt.handler(base));r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({name: SITE_NAME,summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',@@@ -23,6 +23,7 @@const express = require('express');const path = require('path');const cache = require('openvibe-shared/cache-policy');++const securityTxt = require('../http/security-txt');const seo = require('openvibe-shared/seo');const ovServe = require('openvibe-shared/serve');const { asyncRouter } = require('../http/router');@@@ -1746,6 +1747,8 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and downreturn res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}`);});++ // RFC 9116, canonical to this host (openvibe.video), not the drive's.++ r.get('/.well-known/security.txt', securityTxt.handler(base));r.get('/llms.txt', async (req, res) => {const [vods, clips] = await Promise.all([@@@ -7,6 +7,7 @@*/const assert = require('assert');const fs = require('fs');++const http = require('http');const path = require('path');const { boot, check, done } = require('./helpers/boot');@@@ -14,9 +15,45 @@ const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ST// llms.txt names the public machine endpoints on purpose, so only sign-in and the API are private there.const PRIVATE = ['/auth/', '/api/'];++/** A GET with a chosen Host, through node's http: fetch() silently drops a Host header, and this app routes on it. */++function onHost(base, p, host) {++ const u = new URL(base);++ return new Promise((resolve, reject) => {++ http.get({ host: u.hostname, port: u.port, path: p, headers: { host } }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: body }));++ }).on('error', reject);++ });++}++(async () => {const t = await boot();try {++ await check('security.txt: RFC 9116 on every host, canonical to that host', async () => {++ // The drive host, then the two product hosts that share this app, each with its own Canonical.++ for (const site of ['openvibe.download', 'openvibe.video', 'openvibe.pics']) {++ const r = await onHost(t.base, '/.well-known/security.txt', site);++ const where = site;++ assert.strictEqual(r.status, 200, `${where} answers security.txt`);++ assert.strictEqual(r.headers['content-type'], 'text/plain; charset=utf-8', `${where} is text/plain; charset=utf-8`);++ assert.strictEqual(r.headers['cache-control'], 'public, max-age=86400', `${where} is cached for a day`);++ const fields = new Map();++ for (const line of r.text.split('\n')) if (line.trim()) {++ const m = line.match(/^([A-Za-z-]+): (.+)$/);++ assert.ok(m, `${where}: every line is "Name: value" (got "${line}")`);++ fields.set(m[1], m[2]);++ }++ for (const key of ['Contact', 'Expires', 'Preferred-Languages', 'Canonical', 'Policy']) assert.ok(fields.has(key), `${where} names ${key}`);++ assert.strictEqual(fields.get('Contact'), 'mailto:[email protected]');++ assert.ok(Date.parse(fields.get('Expires')) > Date.now(), `${where}: Expires is in the future`);++ const canonical = new URL(fields.get('Canonical'));++ assert.strictEqual(canonical.host, site, `${where}: Canonical is the site's own host`);++ assert.strictEqual(canonical.pathname, '/.well-known/security.txt');++ }++ });++await check('robots.txt: 200 text/plain, sign-in and the API disallowed, the public pages crawlable, sitemap named', async () => {const r = await t.get('/robots.txt');assert.strictEqual(r.status, 200);
@@ -12,6 +12,7 @@ const path = require('path');
@@ -12,6 +12,7 @@ const path = require('path');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const securityTxt = require('./security-txt');
const SITE_NAME = 'OpenVibe.Download';
const SITE_NAME = 'OpenVibe.Download';
const DESCRIPTION = 'A private drive on your OpenVibe account: upload files, share them with links you can take back, and back up a folder from your computer.';
const DESCRIPTION = 'A private drive on your OpenVibe account: upload files, share them with links you can take back, and back up a folder from your computer.';
@@ -114,6 +115,9 @@ function createDiscoveryRoutes(ctx) {
@@ -114,6 +115,9 @@ function createDiscoveryRoutes(ctx) {
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
+ // RFC 9116: the host's own canonical URL, this service's drive host.
+ r.get('/.well-known/security.txt', securityTxt.handler(site));
+
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+
+/**
+ * /.well-known/security.txt (RFC 9116, SEO-30) for every host this service answers for — openvibe.download and its
+ * pages, openvibe.video and openvibe.pics. Contact, Expires and Canonical are required; the canonical URL is the
+ * host's own, so the same helper serves all three without a hard-coded host.
+ */
+
+// The date the file must be refreshed by: one year on, a person re-reads and re-commits it.
+const EXPIRES = '2027-10-29T00:00:00.000Z';
+const CONTACT = 'mailto:[email protected]';
+// Listed only while https://openvibe.network/security answers 200 (checked 2026-10-29).
+const POLICY = 'https://openvibe.network/security';
+
+function securityTxt(origin) {
+ const site = String(origin).replace(/\/+$/, '');
+ return [
+ `Contact: ${CONTACT}`,
+ `Expires: ${EXPIRES}`,
+ 'Preferred-Languages: en',
+ `Canonical: ${site}/.well-known/security.txt`,
+ `Policy: ${POLICY}`,
+ '',
+ ].join('\n');
+}
+
+/** The route itself: text/plain, a day's cache. */
+function handler(origin) {
+ const body = securityTxt(origin);
+ return (_req, res) => res.type('text/plain; charset=utf-8').set('Cache-Control', 'public, max-age=86400').send(body);
+}
+
+module.exports = { securityTxt, handler, EXPIRES, CONTACT, POLICY };
@@ -23,6 +23,7 @@ const fs = require('fs');
@@ -23,6 +23,7 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const securityTxt = require('../http/security-txt');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
@@ -577,6 +578,8 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
@@ -577,6 +578,8 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
// The indexable pages: the home, the update log and the rules. No picture or album is ever listed.
// The indexable pages: the home, the update log and the rules. No picture or album is ever listed.
r.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
r.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(seo.sitemapXml(['/', '/updates', ...legal.PATHS].map((p) => ({ loc: `${base}${p}` })))));
.send(seo.sitemapXml(['/', '/updates', ...legal.PATHS].map((p) => ({ loc: `${base}${p}` })))));
+ // RFC 9116, canonical to this host (openvibe.pics).
+ r.get('/.well-known/security.txt', securityTxt.handler(base));
r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
const express = require('express');
const express = require('express');
const path = require('path');
const path = require('path');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const securityTxt = require('../http/security-txt');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
@@ -1746,6 +1747,8 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
@@ -1746,6 +1747,8 @@ ${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down
return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}`);
.send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}`);
});
});
+ // RFC 9116, canonical to this host (openvibe.video), not the drive's.
+ r.get('/.well-known/security.txt', securityTxt.handler(base));
r.get('/llms.txt', async (req, res) => {
r.get('/llms.txt', async (req, res) => {
const [vods, clips] = await Promise.all([
const [vods, clips] = await Promise.all([
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
+const http = require('http');
const path = require('path');
const path = require('path');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -14,9 +15,45 @@ const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ST
@@ -14,9 +15,45 @@ const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ST
// llms.txt names the public machine endpoints on purpose, so only sign-in and the API are private there.
// llms.txt names the public machine endpoints on purpose, so only sign-in and the API are private there.
const PRIVATE = ['/auth/', '/api/'];
const PRIVATE = ['/auth/', '/api/'];
+/** A GET with a chosen Host, through node's http: fetch() silently drops a Host header, and this app routes on it. */
+function onHost(base, p, host) {
+ const u = new URL(base);
+ return new Promise((resolve, reject) => {
+ http.get({ host: u.hostname, port: u.port, path: p, headers: { host } }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: body }));
+ }).on('error', reject);
+ });
+}
+
(async () => {
(async () => {
const t = await boot();
const t = await boot();
try {
try {
+ await check('security.txt: RFC 9116 on every host, canonical to that host', async () => {
+ // The drive host, then the two product hosts that share this app, each with its own Canonical.
+ for (const site of ['openvibe.download', 'openvibe.video', 'openvibe.pics']) {
+ const r = await onHost(t.base, '/.well-known/security.txt', site);
+ const where = site;
+ assert.strictEqual(r.status, 200, `${where} answers security.txt`);
+ assert.strictEqual(r.headers['content-type'], 'text/plain; charset=utf-8', `${where} is text/plain; charset=utf-8`);
+ assert.strictEqual(r.headers['cache-control'], 'public, max-age=86400', `${where} is cached for a day`);
+ const fields = new Map();
+ for (const line of r.text.split('\n')) if (line.trim()) {
+ const m = line.match(/^([A-Za-z-]+): (.+)$/);
+ assert.ok(m, `${where}: every line is "Name: value" (got "${line}")`);
+ fields.set(m[1], m[2]);
+ }
+ for (const key of ['Contact', 'Expires', 'Preferred-Languages', 'Canonical', 'Policy']) assert.ok(fields.has(key), `${where} names ${key}`);
+ assert.strictEqual(fields.get('Contact'), 'mailto:[email protected]');
+ assert.ok(Date.parse(fields.get('Expires')) > Date.now(), `${where}: Expires is in the future`);
+ const canonical = new URL(fields.get('Canonical'));
+ assert.strictEqual(canonical.host, site, `${where}: Canonical is the site's own host`);
+ assert.strictEqual(canonical.pathname, '/.well-known/security.txt');
+ }
+ });
+
await check('robots.txt: 200 text/plain, sign-in and the API disallowed, the public pages crawlable, sitemap named', async () => {
await check('robots.txt: 200 text/plain, sign-in and the API disallowed, the public pages crawlable, sitemap named', async () => {
const r = await t.get('/robots.txt');
const r = await t.get('/robots.txt');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);