From 2768a71c638faf4acc9a6e3b7d069989f709b638 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Tue, 23 Jun 2026 10:20:28 +1000 Subject: [PATCH] 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 --- db/schema.sql | 7 ++++++ lib/licenses.js | 5 +++-- lib/store.js | 34 +++++++++++++++++++++++++++++ public/app.js | 39 +++++++++++++++++++++++++++++++++ public/index.html | 4 ++++ public/styles.css | 7 ++++++ server.js | 55 ++++++++++++++++++++++++++++++++++++++--------- 7 files changed, 139 insertions(+), 12 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index d979465..fd014c0 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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, diff --git a/lib/licenses.js b/lib/licenses.js index 7a7a489..5b4e569 100644 --- a/lib/licenses.js +++ b/lib/licenses.js @@ -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; diff --git a/lib/store.js b/lib/store.js index 6627347..9396244 100644 --- a/lib/store.js +++ b/lib/store.js @@ -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, }; diff --git a/public/app.js b/public/app.js index f222434..bb59b54 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = '

No licenses yet — import some models.

'; return; } + const opt = (v, sel) => `