Compare commits

..

No commits in common. "a2e08ecd03c5b1862b10762c9a9e7a4b93873499" and "5af88eb5ce444ed090e8d590d43dd66ad12396a4" have entirely different histories.

7 changed files with 25 additions and 155 deletions

View file

@ -2,13 +2,6 @@
-- 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,9 +11,8 @@ 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,
// 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)) {
// 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)) {
return true;
}
return null;

View file

@ -208,47 +208,6 @@ 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]);
}
// 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
async function countUsers() {
const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users');
@ -328,6 +287,4 @@ module.exports = {
getSetting, setSetting,
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
createPasswordReset, getValidReset, markResetUsed,
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
reconcileModelsToRules,
};

View file

@ -420,7 +420,6 @@ 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;
}
@ -557,6 +556,8 @@ function openEditModal() {
document.getElementById('editDescription').value = model.description || '';
document.getElementById('editTags').value = (model.tags || []).join(', ');
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 || {};
document.getElementById('editMaterial').value = ps.material || '';
@ -575,10 +576,12 @@ function openEditModal() {
async function handleEditModel(e) {
e.preventDefault();
const id = editModal.dataset.editId;
const commercial = document.getElementById('editCommercial').value;
const body = {
name: document.getElementById('editName').value,
description: document.getElementById('editDescription').value,
license: document.getElementById('editLicense').value || null,
commercial_use: commercial === '' ? null : commercial === 'true',
tags: splitTags(document.getElementById('editTags').value),
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
print_settings: {
@ -773,44 +776,6 @@ 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,10 +230,6 @@
</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>
@ -433,7 +429,14 @@
<div class="form-group">
<label for="editLicense">License</label>
<input type="text" id="editLicense">
<p class="form-hint">Sellable/non-commercial is set per license in Settings → License types.</p>
</div>
<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 class="form-group">
<label>Print Settings</label>

View file

@ -262,10 +262,3 @@ 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,31 +245,6 @@ 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));
}
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).
app.post('/api/setup/complete', async (req, res, next) => {
try {
@ -338,11 +313,7 @@ app.post('/api/models/:id/files', upload.array('files'), async (req, res, next)
app.put('/api/models/:id', async (req, res, next) => {
try {
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);
const model = await store.updateModel(req.params.id, req.body);
if (!model) return res.status(404).json({ error: 'Model not found' });
res.json(publicModel(model));
} catch (e) { next(e); }
@ -368,23 +339,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: await resolveCommercial('Cubee3D Commercial License', true) };
if (site === 'cubee3d') return { license: 'Cubee3D Commercial License', commercial: 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: await resolveCommercial(p.license, licenses.inferCommercial(p.licenseContent || p.license)) };
return { license: p.license, commercial: 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: await resolveCommercial(meta.license), description: meta.description };
return { license: meta.license, commercial: licenses.inferCommercial(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: await resolveCommercial(license), description: meta.description };
return { license, commercial: licenses.inferCommercial(license), description: meta.description };
}
// Re-download files from the source into an existing model (recovers imports
@ -582,7 +553,7 @@ app.post('/api/import', async (req, res, next) => {
return [];
});
license = makerworld.parseLicense(html) || license;
commercial = await resolveCommercial(license);
commercial = licenses.inferCommercial(license);
} else if (detected.site === 'printables' && detected.id) {
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
console.warn('Printables file download failed:', err.message);
@ -590,13 +561,13 @@ app.post('/api/import', async (req, res, next) => {
});
files = result.files;
if (result.license) license = result.license;
commercial = result.commercial ?? await resolveCommercial(license);
commercial = result.commercial ?? licenses.inferCommercial(license);
} else if (detected.site === 'cubee3d') {
// Auth-gated Wix site — files added manually. You hold a commercial license.
license = 'Cubee3D Commercial License';
commercial = await resolveCommercial(license, true);
commercial = true;
} else {
commercial = await resolveCommercial(license);
commercial = licenses.inferCommercial(license);
}
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
@ -648,17 +619,6 @@ 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()) {
@ -716,7 +676,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 = await resolveCommercial(model.license, licenses.inferCommercial(model.licenseContent || model.license));
const commercial = licenses.inferCommercial(model.licenseContent || model.license);
return { files, license: model.license, commercial };
}
@ -767,7 +727,7 @@ async function downloadAndCreateModel(meta, projects) {
source_url: meta.source_url,
source_id: meta.source_id,
license: meta.license,
commercial_use: await resolveCommercial(meta.license),
commercial_use: licenses.inferCommercial(meta.license),
designer: meta.designer,
tags: meta.tags,
projects,