openvibers/openvibe.network Commits
Domains: mirror role (many per tool), in-place table migration, admin wording
Changes
@@@ -817,11 +817,11 @@ async function loadDomains() {const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;el.innerHTML = `<div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>-- <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, and any number of <b>aliases</b> that redirect to the short one. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>++ <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p><div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end"><div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div><div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>-- <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>++ <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects to the short host)</option><option value="mirror">mirror (serves the tool, canonical tag points at the SEO host)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div><div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div><div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div></div>@@@ -833,7 +833,7 @@ async function loadDomains() {<td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td><td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td><td>${esc(d.note || '')}</td>-- <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>++ <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select><button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}</tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}</div>@@@ -28,12 +28,24 @@ const dnsCheck = require('./dns-check');const NOTE_MAX = 500;function ensureSchema(db) {++ // 'mirror' joined the roles after the table shipped; SQLite cannot alter a CHECK, so rebuild once.++ try {++ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tool_domains'").get();++ if (row && /CHECK \(role IN/.test(row.sql) && !/'mirror'/.test(row.sql)) {++ db.exec('BEGIN; ALTER TABLE tool_domains RENAME TO tool_domains_old;');++ _createTable(db);++ db.exec('INSERT INTO tool_domains SELECT * FROM tool_domains_old; DROP TABLE tool_domains_old; COMMIT;');++ }++ } catch (err) { try { db.exec('ROLLBACK'); } catch { /* */ } console.error('[Domains] role migration failed:', err.message); }++ _createTable(db);++}++function _createTable(db) {db.exec(`CREATE TABLE IF NOT EXISTS tool_domains (id INTEGER PRIMARY KEY AUTOINCREMENT,tool_id TEXT NOT NULL,host TEXT NOT NULL UNIQUE,-- role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')),++ role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),enabled INTEGER NOT NULL DEFAULT 1,note TEXT,created_by INTEGER,@@@ -137,7 +149,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {/** Write inside one transaction; returns the row plus whatever was demoted to make room. */const write = db.transaction((next, current, userId) => {const demoted = [];-- if (next.enabled && next.role !== 'alias') {++ if (next.enabled && (next.role === 'canonical' || next.role === 'short')) { // one of each; aliases and mirrors can be manyfor (const row of q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {q.demote.run(row.id);demoted.push({ id: row.id, host: row.host, from: row.role });@@@ -3,7 +3,7 @@const net = require('net');const { OWNED_ZONES } = require('../auth/sso-owned');--const ROLES = ['canonical', 'short', 'alias'];++const ROLES = ['canonical', 'short', 'alias', 'mirror'];const TOOLS_ZONE = 'openvibe.tools';// Hosts under openvibe.tools that are the platform itself, never a tool.const RESERVED_TOOLS_SUBS = new Set(['www', 'api', 'admin', 'static', 'cdn', 'mail']);@@@ -16,6 +16,11 @@ x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'YoutubeDownloax=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'other.com',role:'canonical'});assert.ok(x.s<300);x=await j('POST','/api/admin/domains','owner',{tool_id:'nope',host:'a.com',role:'alias'});assert.equal(x.s,400);x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'bad host/../',role:'alias'});assert.equal(x.s,400);++x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'youtubedownloader.openvibe.tools',role:'mirror'});assert.ok(x.s<300,'mirror role accepted: '+JSON.stringify(x.j));++x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'ytmirror.example.com',role:'mirror'});assert.ok(x.s<300,'a tool may have several mirrors');x=await j('GET','/api/domains');console.log(x.s,JSON.stringify(x.j.domains));assert.equal(x.j.domains.filter(d=>d.role==='canonical').length,1);++assert.equal(x.j.domains.filter(d=>d.role==='mirror').length,2);++// an old table (CHECK without 'mirror') is rebuilt in place, rows kept++{const D=require('better-sqlite3');const old=new D(':memory:');old.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY,user_id INT,action TEXT,details TEXT,ip TEXT);CREATE TABLE tool_domains (id INTEGER PRIMARY KEY AUTOINCREMENT, tool_id TEXT NOT NULL, host TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')), enabled INTEGER NOT NULL DEFAULT 1, note TEXT, created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','keep.example.com','alias')");require('../server/domains/routes').ensureSchema(old);old.prepare("INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','m.example.com','mirror')").run();assert.equal(old.prepare('SELECT COUNT(*) c FROM tool_domains').get().c,2,'rows survive the rebuild');}console.log('audit rows',db.prepare('select count(*) c from audit_log').get().c);console.log('domains: all checks passed');s.close();});
@@ -817,11 +817,11 @@ async function loadDomains() {
@@ -817,11 +817,11 @@ async function loadDomains() {
const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;
const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;
el.innerHTML = `
el.innerHTML = `
<div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
<div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
- <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, and any number of <b>aliases</b> that redirect to the short one. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
+ <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
<div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
<div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
<div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>
<div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>
<div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>
<div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>
- <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>
+ <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects to the short host)</option><option value="mirror">mirror (serves the tool, canonical tag points at the SEO host)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>
<div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>
<div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>
<div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
<div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
</div>
</div>
@@ -833,7 +833,7 @@ async function loadDomains() {
@@ -833,7 +833,7 @@ async function loadDomains() {
<td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
<td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
<td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
<td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
<td>${esc(d.note || '')}</td>
<td>${esc(d.note || '')}</td>
- <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>
+ <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>
<button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}
<button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}
</tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}
</tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}
</div>
</div>
@@ -28,12 +28,24 @@ const dnsCheck = require('./dns-check');
@@ -28,12 +28,24 @@ const dnsCheck = require('./dns-check');
const NOTE_MAX = 500;
const NOTE_MAX = 500;
function ensureSchema(db) {
function ensureSchema(db) {
+ // 'mirror' joined the roles after the table shipped; SQLite cannot alter a CHECK, so rebuild once.
+ try {
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tool_domains'").get();
+ if (row && /CHECK \(role IN/.test(row.sql) && !/'mirror'/.test(row.sql)) {
+ db.exec('BEGIN; ALTER TABLE tool_domains RENAME TO tool_domains_old;');
+ _createTable(db);
+ db.exec('INSERT INTO tool_domains SELECT * FROM tool_domains_old; DROP TABLE tool_domains_old; COMMIT;');
+ }
+ } catch (err) { try { db.exec('ROLLBACK'); } catch { /* */ } console.error('[Domains] role migration failed:', err.message); }
+ _createTable(db);
+}
+function _createTable(db) {
db.exec(`
db.exec(`
CREATE TABLE IF NOT EXISTS tool_domains (
CREATE TABLE IF NOT EXISTS tool_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id TEXT NOT NULL,
tool_id TEXT NOT NULL,
host TEXT NOT NULL UNIQUE,
host TEXT NOT NULL UNIQUE,
- role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')),
+ role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),
enabled INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
note TEXT,
note TEXT,
created_by INTEGER,
created_by INTEGER,
@@ -137,7 +149,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -137,7 +149,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
/** Write inside one transaction; returns the row plus whatever was demoted to make room. */
/** Write inside one transaction; returns the row plus whatever was demoted to make room. */
const write = db.transaction((next, current, userId) => {
const write = db.transaction((next, current, userId) => {
const demoted = [];
const demoted = [];
- if (next.enabled && next.role !== 'alias') {
+ if (next.enabled && (next.role === 'canonical' || next.role === 'short')) { // one of each; aliases and mirrors can be many
for (const row of q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {
for (const row of q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {
q.demote.run(row.id);
q.demote.run(row.id);
demoted.push({ id: row.id, host: row.host, from: row.role });
demoted.push({ id: row.id, host: row.host, from: row.role });
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
const net = require('net');
const net = require('net');
const { OWNED_ZONES } = require('../auth/sso-owned');
const { OWNED_ZONES } = require('../auth/sso-owned');
-const ROLES = ['canonical', 'short', 'alias'];
+const ROLES = ['canonical', 'short', 'alias', 'mirror'];
const TOOLS_ZONE = 'openvibe.tools';
const TOOLS_ZONE = 'openvibe.tools';
// Hosts under openvibe.tools that are the platform itself, never a tool.
// Hosts under openvibe.tools that are the platform itself, never a tool.
const RESERVED_TOOLS_SUBS = new Set(['www', 'api', 'admin', 'static', 'cdn', 'mail']);
const RESERVED_TOOLS_SUBS = new Set(['www', 'api', 'admin', 'static', 'cdn', 'mail']);
@@ -16,6 +16,11 @@ x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'YoutubeDownloa
@@ -16,6 +16,11 @@ x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'YoutubeDownloa
x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'other.com',role:'canonical'});assert.ok(x.s<300);
x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'other.com',role:'canonical'});assert.ok(x.s<300);
x=await j('POST','/api/admin/domains','owner',{tool_id:'nope',host:'a.com',role:'alias'});assert.equal(x.s,400);
x=await j('POST','/api/admin/domains','owner',{tool_id:'nope',host:'a.com',role:'alias'});assert.equal(x.s,400);
x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'bad host/../',role:'alias'});assert.equal(x.s,400);
x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'bad host/../',role:'alias'});assert.equal(x.s,400);
+x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'youtubedownloader.openvibe.tools',role:'mirror'});assert.ok(x.s<300,'mirror role accepted: '+JSON.stringify(x.j));
+x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'ytmirror.example.com',role:'mirror'});assert.ok(x.s<300,'a tool may have several mirrors');
x=await j('GET','/api/domains');console.log(x.s,JSON.stringify(x.j.domains));
x=await j('GET','/api/domains');console.log(x.s,JSON.stringify(x.j.domains));
assert.equal(x.j.domains.filter(d=>d.role==='canonical').length,1);
assert.equal(x.j.domains.filter(d=>d.role==='canonical').length,1);
+assert.equal(x.j.domains.filter(d=>d.role==='mirror').length,2);
+// an old table (CHECK without 'mirror') is rebuilt in place, rows kept
+{const D=require('better-sqlite3');const old=new D(':memory:');old.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY,user_id INT,action TEXT,details TEXT,ip TEXT);CREATE TABLE tool_domains (id INTEGER PRIMARY KEY AUTOINCREMENT, tool_id TEXT NOT NULL, host TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')), enabled INTEGER NOT NULL DEFAULT 1, note TEXT, created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','keep.example.com','alias')");require('../server/domains/routes').ensureSchema(old);old.prepare("INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','m.example.com','mirror')").run();assert.equal(old.prepare('SELECT COUNT(*) c FROM tool_domains').get().c,2,'rows survive the rebuild');}
console.log('audit rows',db.prepare('select count(*) c from audit_log').get().c);console.log('domains: all checks passed');s.close();});
console.log('audit rows',db.prepare('select count(*) c from audit_log').get().c);console.log('domains: all checks passed');s.close();});