openvibers/openvibe.space Commits
Sitemaps: every entry carries an honest lastmod (item changes, release time for static pages) (#38)
Changes
@@@ -42,6 +42,8 @@ Nothing that is not a forum path redirects.| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page || `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Health, release and observability |++Sitemap entries carry lastmod from the release commit or, for hosted pages, the active deploy.++Tenant requests are dispatched by Host header before dashboard routes. `deploy/nginx/openvibe.website.conf` is a reference wildcard vhost; the existing `openvibe.space` vhost includes larger upload locations. Neither is installed by this code change. Unknown hosts answer 404. Tenant sites cannot reach the dashboard, API, auth or metrics. A custom domain serves only after TXT verification.## Capabilities and events@@@ -157,7 +157,7 @@ async function createApp(opts = {}) {.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));app.get('/sitemap.xml', async (_req, res) => {let rows;-- try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }++ try { rows = await discovery.sitemapRows(release.full().released_at === release.full().booted_at ? undefined : release.full().released_at); } catch { return res.status(503).end(); }res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));});@@@ -39,8 +39,8 @@ async function llmsFullSections() {// ── sitemap.xml rows ─────────────────────────────────────────/** Space's only page is the home page. */--async function sitemapRows() {-- return [{ loc: abs('/'), lastmod: null, changefreq: 'weekly', priority: '1.0' }];++async function sitemapRows(releaseLastmod) {++ return [{ loc: abs('/'), lastmod: releaseLastmod, changefreq: 'weekly', priority: '1.0' }];}module.exports = { ROBOTS_DISALLOW, SUMMARY, llmsSections, llmsFullSections, sitemapRows };@@@ -132,6 +132,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigfile: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM space_deploy_files f JOIN space_deploys d ON d.id = f.deploy_idWHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),htmlFiles: db.prepare("SELECT path FROM space_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),++ deployTime: db.prepare('SELECT created_at FROM space_deploys WHERE id = ?'),deployById: db.prepare('SELECT id, site_id, project_id, state FROM space_deploys WHERE id = ?'),siteById: db.prepare('SELECT * FROM space_sites WHERE id = ?'),projectById: db.prepare('SELECT id, status, environment FROM space_projects WHERE id = ?'),@@@ -256,6 +257,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigconst origin = originOf(req);if (!origin) return await notFound(req, res, site, 'invalid');const locs = [];++ const deploy = await q.deployTime.get(site.active_deploy_id);++ const lastmod = deploy && deploy.created_at ? new Date(deploy.created_at).toISOString() : undefined;const seen = new Set();for (const row of await q.htmlFiles.all(site.active_deploy_id)) {const p = pagePath(row.path);@@@ -263,7 +266,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigconst loc = `${origin}${p}`;if (seen.has(loc)) continue;seen.add(loc);-- locs.push({ loc });++ locs.push({ loc, lastmod });}const body = sitemapXml(locs);res.statusCode = 200;@@@ -5,6 +5,17 @@* files describe the same site and the sitemap holds the home page alone; health and readiness answer.*/const assert = require('assert');++++function assertSitemapLastmods(xml, tag = 'url') {++ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];++ for (const [, body] of entries) {++ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);++ assert.ok(match, body);++ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);++ }++ return entries;++}++const { boot, check, done } = require('./helpers/app');(async () => {@@@ -45,6 +56,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(r.status, 200);const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);assert.deepStrictEqual(locs, ['https://openvibe.space/']);++ assert.strictEqual(assertSitemapLastmods(r.text).length, 1);});await check('health and readiness answer, and readiness names the store', async () => {@@@ -7,6 +7,17 @@* either path always wins. Neither generated body carries a secret, and both say how to cache them.*/const assert = require('assert');++++function assertSitemapLastmods(xml, tag = 'url') {++ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];++ for (const [, body] of entries) {++ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);++ assert.ok(match, body);++ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);++ }++ return entries;++}++const { boot, check, done } = require('./stageb/boot');const HOST = 'widgets.openvibe.website';@@@ -19,7 +30,7 @@ const CUSTOM = 'www.widgets-example.org';const site = await t.site(alice, project.id, 'widgets');const origin = `https://${HOST}`;-- await t.deploy(alice, site.id, {++ const firstDeploy = await t.deploy(alice, site.id, {'index.html': '<h1>Home</h1>','about.html': '<h1>About</h1>','docs/index.html': '<h1>Docs</h1>',@@@ -34,6 +45,8 @@ const CUSTOM = 'www.widgets-example.org';assert.match(r.headers['content-type'], /application\/xml/);const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());++ assert.strictEqual(assertSitemapLastmods(r.text).length, 3);++ assert.ok(r.text.includes(`<lastmod>${new Date(firstDeploy.created_at).toISOString().slice(0, 10)}</lastmod>`));});await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {
@@ -42,6 +42,8 @@ Nothing that is not a forum path redirects.
@@ -42,6 +42,8 @@ Nothing that is not a forum path redirects.
| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |
| `/robots.txt`, `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` | Discovery for Space's public page |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Health, release and observability |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Health, release and observability |
+Sitemap entries carry lastmod from the release commit or, for hosted pages, the active deploy.
+
Tenant requests are dispatched by Host header before dashboard routes. `deploy/nginx/openvibe.website.conf` is a reference wildcard vhost; the existing `openvibe.space` vhost includes larger upload locations. Neither is installed by this code change. Unknown hosts answer 404. Tenant sites cannot reach the dashboard, API, auth or metrics. A custom domain serves only after TXT verification.
Tenant requests are dispatched by Host header before dashboard routes. `deploy/nginx/openvibe.website.conf` is a reference wildcard vhost; the existing `openvibe.space` vhost includes larger upload locations. Neither is installed by this code change. Unknown hosts answer 404. Tenant sites cannot reach the dashboard, API, auth or metrics. A custom domain serves only after TXT verification.
## Capabilities and events
## Capabilities and events
@@ -157,7 +157,7 @@ async function createApp(opts = {}) {
@@ -157,7 +157,7 @@ async function createApp(opts = {}) {
.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
app.get('/sitemap.xml', async (_req, res) => {
app.get('/sitemap.xml', async (_req, res) => {
let rows;
let rows;
- try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
+ try { rows = await discovery.sitemapRows(release.full().released_at === release.full().booted_at ? undefined : release.full().released_at); } catch { return res.status(503).end(); }
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));
});
});
@@ -39,8 +39,8 @@ async function llmsFullSections() {
@@ -39,8 +39,8 @@ async function llmsFullSections() {
// ── sitemap.xml rows ─────────────────────────────────────────
// ── sitemap.xml rows ─────────────────────────────────────────
/** Space's only page is the home page. */
/** Space's only page is the home page. */
-async function sitemapRows() {
- return [{ loc: abs('/'), lastmod: null, changefreq: 'weekly', priority: '1.0' }];
+async function sitemapRows(releaseLastmod) {
+ return [{ loc: abs('/'), lastmod: releaseLastmod, changefreq: 'weekly', priority: '1.0' }];
}
}
module.exports = { ROBOTS_DISALLOW, SUMMARY, llmsSections, llmsFullSections, sitemapRows };
module.exports = { ROBOTS_DISALLOW, SUMMARY, llmsSections, llmsFullSections, sitemapRows };
@@ -132,6 +132,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -132,6 +132,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
file: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM space_deploy_files f JOIN space_deploys d ON d.id = f.deploy_id
file: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM space_deploy_files f JOIN space_deploys d ON d.id = f.deploy_id
WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),
WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),
htmlFiles: db.prepare("SELECT path FROM space_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),
htmlFiles: db.prepare("SELECT path FROM space_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),
+ deployTime: db.prepare('SELECT created_at FROM space_deploys WHERE id = ?'),
deployById: db.prepare('SELECT id, site_id, project_id, state FROM space_deploys WHERE id = ?'),
deployById: db.prepare('SELECT id, site_id, project_id, state FROM space_deploys WHERE id = ?'),
siteById: db.prepare('SELECT * FROM space_sites WHERE id = ?'),
siteById: db.prepare('SELECT * FROM space_sites WHERE id = ?'),
projectById: db.prepare('SELECT id, status, environment FROM space_projects WHERE id = ?'),
projectById: db.prepare('SELECT id, status, environment FROM space_projects WHERE id = ?'),
@@ -256,6 +257,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -256,6 +257,8 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
const origin = originOf(req);
const origin = originOf(req);
if (!origin) return await notFound(req, res, site, 'invalid');
if (!origin) return await notFound(req, res, site, 'invalid');
const locs = [];
const locs = [];
+ const deploy = await q.deployTime.get(site.active_deploy_id);
+ const lastmod = deploy && deploy.created_at ? new Date(deploy.created_at).toISOString() : undefined;
const seen = new Set();
const seen = new Set();
for (const row of await q.htmlFiles.all(site.active_deploy_id)) {
for (const row of await q.htmlFiles.all(site.active_deploy_id)) {
const p = pagePath(row.path);
const p = pagePath(row.path);
@@ -263,7 +266,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -263,7 +266,7 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
const loc = `${origin}${p}`;
const loc = `${origin}${p}`;
if (seen.has(loc)) continue;
if (seen.has(loc)) continue;
seen.add(loc);
seen.add(loc);
- locs.push({ loc });
+ locs.push({ loc, lastmod });
}
}
const body = sitemapXml(locs);
const body = sitemapXml(locs);
res.statusCode = 200;
res.statusCode = 200;
@@ -5,6 +5,17 @@
@@ -5,6 +5,17 @@
* files describe the same site and the sitemap holds the home page alone; health and readiness answer.
* files describe the same site and the sitemap holds the home page alone; health and readiness answer.
*/
*/
const assert = require('assert');
const assert = require('assert');
+
+function assertSitemapLastmods(xml, tag = 'url') {
+ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
+ for (const [, body] of entries) {
+ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
+ assert.ok(match, body);
+ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
+ }
+ return entries;
+}
+
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
@@ -45,6 +56,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -45,6 +56,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
assert.deepStrictEqual(locs, ['https://openvibe.space/']);
assert.deepStrictEqual(locs, ['https://openvibe.space/']);
+ assert.strictEqual(assertSitemapLastmods(r.text).length, 1);
});
});
await check('health and readiness answer, and readiness names the store', async () => {
await check('health and readiness answer, and readiness names the store', async () => {
@@ -7,6 +7,17 @@
@@ -7,6 +7,17 @@
* either path always wins. Neither generated body carries a secret, and both say how to cache them.
* either path always wins. Neither generated body carries a secret, and both say how to cache them.
*/
*/
const assert = require('assert');
const assert = require('assert');
+
+function assertSitemapLastmods(xml, tag = 'url') {
+ const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
+ for (const [, body] of entries) {
+ const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
+ assert.ok(match, body);
+ assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
+ }
+ return entries;
+}
+
const { boot, check, done } = require('./stageb/boot');
const { boot, check, done } = require('./stageb/boot');
const HOST = 'widgets.openvibe.website';
const HOST = 'widgets.openvibe.website';
@@ -19,7 +30,7 @@ const CUSTOM = 'www.widgets-example.org';
@@ -19,7 +30,7 @@ const CUSTOM = 'www.widgets-example.org';
const site = await t.site(alice, project.id, 'widgets');
const site = await t.site(alice, project.id, 'widgets');
const origin = `https://${HOST}`;
const origin = `https://${HOST}`;
- await t.deploy(alice, site.id, {
+ const firstDeploy = await t.deploy(alice, site.id, {
'index.html': '<h1>Home</h1>',
'index.html': '<h1>Home</h1>',
'about.html': '<h1>About</h1>',
'about.html': '<h1>About</h1>',
'docs/index.html': '<h1>Docs</h1>',
'docs/index.html': '<h1>Docs</h1>',
@@ -34,6 +45,8 @@ const CUSTOM = 'www.widgets-example.org';
@@ -34,6 +45,8 @@ const CUSTOM = 'www.widgets-example.org';
assert.match(r.headers['content-type'], /application\/xml/);
assert.match(r.headers['content-type'], /application\/xml/);
const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());
assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());
+ assert.strictEqual(assertSitemapLastmods(r.text).length, 3);
+ assert.ok(r.text.includes(`<lastmod>${new Date(firstDeploy.created_at).toISOString().slice(0, 10)}</lastmod>`));
});
});
await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {
await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {