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:
parent
5af88eb5ce
commit
2768a71c63
7 changed files with 139 additions and 12 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
34
lib/store.js
34
lib/store.js
|
|
@ -208,6 +208,39 @@ 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]);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 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 +320,5 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
async function handleSaveSettings(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const status = document.getElementById('settingsStatus');
|
const status = document.getElementById('settingsStatus');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
55
server.js
55
server.js
|
|
@ -245,6 +245,30 @@ 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));
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
|
@ -339,23 +363,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 +577,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 +585,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 +643,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 +711,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 +762,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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue