Compare commits
No commits in common. "a2e08ecd03c5b1862b10762c9a9e7a4b93873499" and "5af88eb5ce444ed090e8d590d43dd66ad12396a4" have entirely different histories.
a2e08ecd03
...
5af88eb5ce
7 changed files with 25 additions and 155 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
43
lib/store.js
43
lib/store.js
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
62
server.js
62
server.js
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue