Compare commits

...

2 commits

Author SHA1 Message Date
dlawler489
a2e08ecd03 License rules are the source of truth for sellability
All checks were successful
build-and-push / docker (push) Successful in 28s
- Changing a license policy applies to all models with that license, and
  opening Settings reconciles old imports to current rules (store.reconcileModelsToRules)
- Editing a model's license re-resolves its sellability from the rule
- Removed the per-model commercial-use override (set by license now)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:33:38 +10:00
dlawler489
2768a71c63 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>
2026-06-23 10:20:28 +10:00
7 changed files with 155 additions and 25 deletions

View file

@ -2,6 +2,13 @@
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a -- 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/...). -- 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). -- App users (email/password auth).
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, 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)) { 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; return false;
} }
// Clearly permissive (commercial use OK). NC variants are already excluded above. // 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)) { // 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 true;
} }
return null; return null;

View file

@ -208,6 +208,47 @@ async function deleteProject(id) {
return rowCount > 0; 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]);
}
// Align every model's commercial_use to its license rule (license = source of truth).
async function reconcileModelsToRules() {
await db.query(`
UPDATE models m SET commercial_use = lr.commercial
FROM license_rules lr
WHERE m.license = lr.name AND m.commercial_use IS DISTINCT FROM lr.commercial`);
}
// ---------------------------------------------------------------- users / auth // ---------------------------------------------------------------- users / auth
async function countUsers() { async function countUsers() {
const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users'); const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users');
@ -287,4 +328,6 @@ module.exports = {
getSetting, setSetting, getSetting, setSetting,
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword, countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
createPasswordReset, getValidReset, markResetUsed, createPasswordReset, getValidReset, markResetUsed,
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
reconcileModelsToRules,
}; };

View file

@ -420,6 +420,7 @@ async function openSettingsModal() {
const s = settings[input.dataset.setting]; const s = settings[input.dataset.setting];
if (s && !s.secret && s.value) input.value = s.value; if (s && !s.secret && s.value) input.value = s.value;
}); });
await renderLicenseRules();
} catch (err) { } catch (err) {
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message; document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
} }
@ -556,8 +557,6 @@ function openEditModal() {
document.getElementById('editDescription').value = model.description || ''; document.getElementById('editDescription').value = model.description || '';
document.getElementById('editTags').value = (model.tags || []).join(', '); document.getElementById('editTags').value = (model.tags || []).join(', ');
document.getElementById('editLicense').value = model.license || ''; document.getElementById('editLicense').value = model.license || '';
document.getElementById('editCommercial').value =
model.commercial_use === true ? 'true' : model.commercial_use === false ? 'false' : '';
const ps = model.print_settings || {}; const ps = model.print_settings || {};
document.getElementById('editMaterial').value = ps.material || ''; document.getElementById('editMaterial').value = ps.material || '';
@ -576,12 +575,10 @@ function openEditModal() {
async function handleEditModel(e) { async function handleEditModel(e) {
e.preventDefault(); e.preventDefault();
const id = editModal.dataset.editId; const id = editModal.dataset.editId;
const commercial = document.getElementById('editCommercial').value;
const body = { const body = {
name: document.getElementById('editName').value, name: document.getElementById('editName').value,
description: document.getElementById('editDescription').value, description: document.getElementById('editDescription').value,
license: document.getElementById('editLicense').value || null, license: document.getElementById('editLicense').value || null,
commercial_use: commercial === '' ? null : commercial === 'true',
tags: splitTags(document.getElementById('editTags').value), tags: splitTags(document.getElementById('editTags').value),
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)), projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
print_settings: { print_settings: {
@ -776,6 +773,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) { async function handleSaveSettings(e) {
e.preventDefault(); e.preventDefault();
const status = document.getElementById('settingsStatus'); const status = document.getElementById('settingsStatus');

View file

@ -230,6 +230,10 @@
</div> </div>
<p class="form-hint">Status: <span class="setting-status" data-status="smtp_host"></span>. Needed for "forgot password" emails.</p> <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"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
@ -429,14 +433,7 @@
<div class="form-group"> <div class="form-group">
<label for="editLicense">License</label> <label for="editLicense">License</label>
<input type="text" id="editLicense"> <input type="text" id="editLicense">
</div> <p class="form-hint">Sellable/non-commercial is set per license in Settings → License types.</p>
<div class="form-group">
<label for="editCommercial">Commercial use (sellable?)</label>
<select id="editCommercial">
<option value="">Unknown</option>
<option value="true">Allowed — sellable</option>
<option value="false">Non-commercial only</option>
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Print Settings</label> <label>Print Settings</label>

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 { color: var(--text-muted); align-self: center; }
.auth-form .btn-link:hover { color: var(--accent); } .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; } .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,31 @@ app.post('/api/auth/reset', async (req, res, next) => {
} catch (e) { next(e); } } 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));
}
await store.reconcileModelsToRules(); // keep old imports aligned to current policy
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). // Mark the first-run setup wizard as done (so it won't show again).
app.post('/api/setup/complete', async (req, res, next) => { app.post('/api/setup/complete', async (req, res, next) => {
try { try {
@ -313,7 +338,11 @@ app.post('/api/models/:id/files', upload.array('files'), async (req, res, next)
app.put('/api/models/:id', async (req, res, next) => { app.put('/api/models/:id', async (req, res, next) => {
try { try {
const model = await store.updateModel(req.params.id, req.body); const updates = { ...req.body };
// Sellability is derived from the license rule, so re-resolve it if the
// license changed (registers the new license if it's not seen before).
if (updates.license !== undefined) updates.commercial_use = await resolveCommercial(updates.license);
const model = await store.updateModel(req.params.id, updates);
if (!model) return res.status(404).json({ error: 'Model not found' }); if (!model) return res.status(404).json({ error: 'Model not found' });
res.json(publicModel(model)); res.json(publicModel(model));
} catch (e) { next(e); } } catch (e) { next(e); }
@ -339,23 +368,23 @@ app.post('/api/models/:id/refresh-metadata', async (req, res, next) => {
// Returns { license, commercial, description } from the model's source. // Returns { license, commercial, description } from the model's source.
async function refreshMetadata(model) { async function refreshMetadata(model) {
const site = model.source_site; 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) { if (site === 'printables' && model.source_id) {
const { value: token } = await resolveSetting('printables_token'); const { value: token } = await resolveSetting('printables_token');
const p = await printables.fetchModel(model.source_id, 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) { if (site === 'thingiverse' && model.source_id) {
const { value: token } = await resolveSetting('thingiverse_token'); const { value: token } = await resolveSetting('thingiverse_token');
if (!token) throw new Error('Thingiverse token required (set it in Settings).'); if (!token) throw new Error('Thingiverse token required (set it in Settings).');
const meta = await thingiverse.fetchModel(model.source_id, token); 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). // makerworld / web: read the page (FlareSolverr handles Cloudflare).
const html = await fetchPageHtml(model.source_url); const html = await fetchPageHtml(model.source_url);
const meta = generic.parseMetadata(html, model.source_url); const meta = generic.parseMetadata(html, model.source_url);
const license = site === 'makerworld' ? makerworld.parseLicense(html) : meta.license; 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 // Re-download files from the source into an existing model (recovers imports
@ -553,7 +582,7 @@ app.post('/api/import', async (req, res, next) => {
return []; return [];
}); });
license = makerworld.parseLicense(html) || license; license = makerworld.parseLicense(html) || license;
commercial = licenses.inferCommercial(license); commercial = await resolveCommercial(license);
} else if (detected.site === 'printables' && detected.id) { } else if (detected.site === 'printables' && detected.id) {
const result = await downloadPrintablesFiles(detected.id).catch((err) => { const result = await downloadPrintablesFiles(detected.id).catch((err) => {
console.warn('Printables file download failed:', err.message); console.warn('Printables file download failed:', err.message);
@ -561,13 +590,13 @@ app.post('/api/import', async (req, res, next) => {
}); });
files = result.files; files = result.files;
if (result.license) license = result.license; if (result.license) license = result.license;
commercial = result.commercial ?? licenses.inferCommercial(license); commercial = result.commercial ?? await resolveCommercial(license);
} else if (detected.site === 'cubee3d') { } else if (detected.site === 'cubee3d') {
// Auth-gated Wix site — files added manually. You hold a commercial license. // Auth-gated Wix site — files added manually. You hold a commercial license.
license = 'Cubee3D Commercial License'; license = 'Cubee3D Commercial License';
commercial = true; commercial = await resolveCommercial(license, true);
} else { } else {
commercial = licenses.inferCommercial(license); commercial = await resolveCommercial(license);
} }
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files); const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
@ -619,6 +648,17 @@ async function fetchPageHtml(url) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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). // Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
// skipNames: original filenames already present (so a re-download only adds new ones). // skipNames: original filenames already present (so a re-download only adds new ones).
async function downloadMakerworldFiles(html, skipNames = new Set()) { async function downloadMakerworldFiles(html, skipNames = new Set()) {
@ -676,7 +716,7 @@ async function downloadPrintablesFiles(printId, skipNames = new Set()) {
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer())); fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
files.push(describeFile(filePath, fileName, f.name)); 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 }; return { files, license: model.license, commercial };
} }
@ -727,7 +767,7 @@ async function downloadAndCreateModel(meta, projects) {
source_url: meta.source_url, source_url: meta.source_url,
source_id: meta.source_id, source_id: meta.source_id,
license: meta.license, license: meta.license,
commercial_use: licenses.inferCommercial(meta.license), commercial_use: await resolveCommercial(meta.license),
designer: meta.designer, designer: meta.designer,
tags: meta.tags, tags: meta.tags,
projects, projects,