Add per-license commercial-use rules (settings + auto-register)

- license_rules table (name -> commercial); licenses auto-register on
  import with a best-effort seed, overridable in Settings (applies the
  choice to every model with that license)
- resolveCommercial() replaces ad-hoc inferCommercial across import/refresh
- GET/PUT /api/licenses; "License types" section in Settings
- Fix CC short codes: BY / BY-SA / BY-ND now classed sellable (MakerWorld
  stores e.g. "BY-SA"); NC variants stay non-commercial

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-23 10:20:28 +10:00
parent 5af88eb5ce
commit 2768a71c63
7 changed files with 139 additions and 12 deletions

View file

@ -2,6 +2,13 @@
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a
-- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...).
-- Per-license commercial-use policy. Auto-registered as licenses are seen;
-- editable in Settings. commercial: true=sellable, false=non-commercial, null=unknown.
CREATE TABLE IF NOT EXISTS license_rules (
name TEXT PRIMARY KEY,
commercial BOOLEAN
);
-- App users (email/password auth).
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,

View file

@ -11,8 +11,9 @@ function inferCommercial(text) {
if (/non-?commercial|personal use|community use|[-_\s]nc([-_\s]|$)|exclusive|standard digital file|all rights reserved|^\s*none\s*$|charge money|shall not[^.]*\bsell|not be sold/.test(s)) {
return false;
}
// Clearly permissive (commercial use OK). NC variants are already excluded above.
if (/cc0|public ?domain|creative commons|attribution|cc[-_\s]?by|^\s*mit\b|^\s*bsd\b|^\s*l?gpl\b|royalty[- ]free|commercial use (is )?(allowed|permitted)|may be sold/.test(s)) {
// Clearly permissive (commercial use OK). NC variants are already excluded above,
// so any remaining CC "BY" code (by, by-sa, by-nd) allows commercial use.
if (/cc0|public ?domain|creative commons|attribution|cc[-_\s]?by|(^|[-_\s])by([-_\s]|$)|by[-_](sa|nd)|share[- ]?alike|^\s*mit\b|^\s*bsd\b|^\s*l?gpl\b|royalty[- ]free|commercial use (is )?(allowed|permitted)|may be sold/.test(s)) {
return true;
}
return null;

View file

@ -208,6 +208,39 @@ async function deleteProject(id) {
return rowCount > 0;
}
// ---------------------------------------------------------------- license rules
async function getLicenseRule(name) {
const { rows } = await db.query('SELECT name, commercial FROM license_rules WHERE name = $1', [name]);
return rows[0] || null;
}
async function upsertLicenseRule(name, commercial) {
await db.query(
`INSERT INTO license_rules (name, commercial) VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET commercial = EXCLUDED.commercial`,
[name, commercial ?? null]);
}
async function listLicenseRules() {
const { rows } = await db.query(`
SELECT lr.name, lr.commercial, COUNT(m.id)::int AS model_count
FROM license_rules lr
LEFT JOIN models m ON m.license = lr.name
GROUP BY lr.name, lr.commercial
ORDER BY lr.name`);
return rows;
}
async function distinctModelLicenses() {
const { rows } = await db.query("SELECT DISTINCT license FROM models WHERE license IS NOT NULL AND license <> ''");
return rows.map((r) => r.license);
}
// Push a rule's value onto every model that carries that license.
async function applyLicenseRuleToModels(name, commercial) {
await db.query('UPDATE models SET commercial_use = $2 WHERE license = $1', [name, commercial ?? null]);
}
// ---------------------------------------------------------------- users / auth
async function countUsers() {
const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users');
@ -287,4 +320,5 @@ module.exports = {
getSetting, setSetting,
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
createPasswordReset, getValidReset, markResetUsed,
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
};

View file

@ -420,6 +420,7 @@ async function openSettingsModal() {
const s = settings[input.dataset.setting];
if (s && !s.secret && s.value) input.value = s.value;
});
await renderLicenseRules();
} catch (err) {
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
}
@ -776,6 +777,44 @@ async function handleImport(e) {
}
}
async function renderLicenseRules() {
const wrap = document.getElementById('licenseRules');
let rules;
try { rules = await (await fetch('/api/licenses')).json(); }
catch { wrap.textContent = 'Could not load licenses.'; return; }
if (!rules.length) { wrap.innerHTML = '<p class="form-hint">No licenses yet — import some models.</p>'; return; }
const opt = (v, sel) => `<option value="${v}"${sel ? ' selected' : ''}>`;
wrap.innerHTML = rules.map((r) => {
const cur = r.commercial === true ? 'true' : r.commercial === false ? 'false' : '';
return `<div class="license-row">
<span class="license-name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)} <em>(${r.model_count})</em></span>
<select class="license-select" data-license="${escapeHtml(r.name)}">
${opt('true', cur === 'true')}Sellable</option>
${opt('false', cur === 'false')}Non-commercial</option>
${opt('', cur === '')}Unknown</option>
</select>
</div>`;
}).join('');
wrap.querySelectorAll('.license-select').forEach((sel) => {
sel.addEventListener('change', () => handleLicenseChange(sel.dataset.license, sel.value));
});
}
async function handleLicenseChange(name, value) {
const commercial = value === 'true' ? true : value === 'false' ? false : null;
try {
const res = await fetch('/api/licenses', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, commercial }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData(); // model badges reflect the new policy
} catch (err) {
alert('Could not update license: ' + err.message);
}
}
async function handleSaveSettings(e) {
e.preventDefault();
const status = document.getElementById('settingsStatus');

View file

@ -230,6 +230,10 @@
</div>
<p class="form-hint">Status: <span class="setting-status" data-status="smtp_host"></span>. Needed for "forgot password" emails.</p>
<h3 class="settings-group-title">License types — which are sellable</h3>
<div id="licenseRules" class="license-rules"></div>
<p class="form-hint">New licenses auto-register as you import. Changing one updates every model with that license.</p>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>

View file

@ -262,3 +262,10 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.auth-form .btn-link { color: var(--text-muted); align-self: center; }
.auth-form .btn-link:hover { color: var(--accent); }
.auth-error { margin-top: 14px; color: var(--text-muted); font-size: 13px; text-align: center; min-height: 18px; }
/* ---- license rules ---- */
.license-rules { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.license-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.license-name { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.license-name em { color: var(--text-dim); font-style: normal; }
.license-select { width: auto; flex-shrink: 0; padding: 6px 10px; font-size: 13px; }

View file

@ -245,6 +245,30 @@ app.post('/api/auth/reset', async (req, res, next) => {
} catch (e) { next(e); }
});
// ----------------------------------------------------------------- license rules
// List all known licenses + their commercial-use policy (auto-registers any
// license currently used by a model that isn't in the table yet).
app.get('/api/licenses', async (req, res, next) => {
try {
for (const name of await store.distinctModelLicenses()) {
if (!(await store.getLicenseRule(name))) await store.upsertLicenseRule(name, licenses.inferCommercial(name));
}
res.json(await store.listLicenseRules());
} catch (e) { next(e); }
});
// Set a license's policy and apply it to every model with that license.
app.put('/api/licenses', async (req, res, next) => {
try {
const { name } = req.body || {};
if (!name) return res.status(400).json({ error: 'License name required' });
const c = req.body.commercial === true ? true : req.body.commercial === false ? false : null;
await store.upsertLicenseRule(name, c);
await store.applyLicenseRuleToModels(name, c);
res.json({ ok: true });
} catch (e) { next(e); }
});
// Mark the first-run setup wizard as done (so it won't show again).
app.post('/api/setup/complete', async (req, res, next) => {
try {
@ -339,23 +363,23 @@ app.post('/api/models/:id/refresh-metadata', async (req, res, next) => {
// Returns { license, commercial, description } from the model's source.
async function refreshMetadata(model) {
const site = model.source_site;
if (site === 'cubee3d') return { license: 'Cubee3D Commercial License', commercial: true };
if (site === 'cubee3d') return { license: 'Cubee3D Commercial License', commercial: await resolveCommercial('Cubee3D Commercial License', true) };
if (site === 'printables' && model.source_id) {
const { value: token } = await resolveSetting('printables_token');
const p = await printables.fetchModel(model.source_id, token);
return { license: p.license, commercial: licenses.inferCommercial(p.licenseContent || p.license) };
return { license: p.license, commercial: await resolveCommercial(p.license, licenses.inferCommercial(p.licenseContent || p.license)) };
}
if (site === 'thingiverse' && model.source_id) {
const { value: token } = await resolveSetting('thingiverse_token');
if (!token) throw new Error('Thingiverse token required (set it in Settings).');
const meta = await thingiverse.fetchModel(model.source_id, token);
return { license: meta.license, commercial: licenses.inferCommercial(meta.license), description: meta.description };
return { license: meta.license, commercial: await resolveCommercial(meta.license), description: meta.description };
}
// makerworld / web: read the page (FlareSolverr handles Cloudflare).
const html = await fetchPageHtml(model.source_url);
const meta = generic.parseMetadata(html, model.source_url);
const license = site === 'makerworld' ? makerworld.parseLicense(html) : meta.license;
return { license, commercial: licenses.inferCommercial(license), description: meta.description };
return { license, commercial: await resolveCommercial(license), description: meta.description };
}
// Re-download files from the source into an existing model (recovers imports
@ -553,7 +577,7 @@ app.post('/api/import', async (req, res, next) => {
return [];
});
license = makerworld.parseLicense(html) || license;
commercial = licenses.inferCommercial(license);
commercial = await resolveCommercial(license);
} else if (detected.site === 'printables' && detected.id) {
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
console.warn('Printables file download failed:', err.message);
@ -561,13 +585,13 @@ app.post('/api/import', async (req, res, next) => {
});
files = result.files;
if (result.license) license = result.license;
commercial = result.commercial ?? licenses.inferCommercial(license);
commercial = result.commercial ?? await resolveCommercial(license);
} else if (detected.site === 'cubee3d') {
// Auth-gated Wix site — files added manually. You hold a commercial license.
license = 'Cubee3D Commercial License';
commercial = true;
commercial = await resolveCommercial(license, true);
} else {
commercial = licenses.inferCommercial(license);
commercial = await resolveCommercial(license);
}
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
@ -619,6 +643,17 @@ async function fetchPageHtml(url) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Commercial-use for a license name, from the license_rules table. Unknown
// licenses are auto-registered with a best-effort default (overridable in Settings).
async function resolveCommercial(licenseName, fallback) {
if (!licenseName) return null;
const rule = await store.getLicenseRule(licenseName);
if (rule) return rule.commercial;
const seed = fallback !== undefined ? fallback : licenses.inferCommercial(licenseName);
await store.upsertLicenseRule(licenseName, seed);
return seed;
}
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
// skipNames: original filenames already present (so a re-download only adds new ones).
async function downloadMakerworldFiles(html, skipNames = new Set()) {
@ -676,7 +711,7 @@ async function downloadPrintablesFiles(printId, skipNames = new Set()) {
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
files.push(describeFile(filePath, fileName, f.name));
}
const commercial = licenses.inferCommercial(model.licenseContent || model.license);
const commercial = await resolveCommercial(model.license, licenses.inferCommercial(model.licenseContent || model.license));
return { files, license: model.license, commercial };
}
@ -727,7 +762,7 @@ async function downloadAndCreateModel(meta, projects) {
source_url: meta.source_url,
source_id: meta.source_id,
license: meta.license,
commercial_use: licenses.inferCommercial(meta.license),
commercial_use: await resolveCommercial(meta.license),
designer: meta.designer,
tags: meta.tags,
projects,