Ask for a fresh MakerWorld token when MakerWorld refuses it, then retry

When a download fails because MakerWorld refused (or is missing) the auth
token, the error carries reason makerworld_token. The import and "Download
files from source" then open an "Update your MakerWorld token" prompt with
steps to copy the token cookie; saving it retries the download straight away
and says if MakerWorld still refuses it. A missing FlareSolverr URL is reported
separately, since a token alone won't help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-15 18:33:10 +10:00
parent 28a95ee330
commit 4a88d66e90
7 changed files with 146 additions and 19 deletions

View file

@ -8,7 +8,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **Import from URL**: - **Import from URL**:
- **Thingiverse** — downloads files automatically via the official API (needs a free app token). - **Thingiverse** — downloads files automatically via the official API (needs a free app token).
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models). - **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. - **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. If files can't be downloaded the import says why, and when MakerWorld refuses the token (they expire) Printhive asks for a fresh one and retries.
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually. - **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects. - **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects.
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden. - **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden.

View file

@ -35,15 +35,15 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) {
}); });
} }
// Why a download-link answer came back without a link, in words you can act on; // Why a download-link answer came back without a link: { message, tokenRejected },
// null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."} // or null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."}
function downloadProblem(answer) { function downloadProblem(answer) {
if (answer && answer.url) return null; if (answer && answer.url) return null;
const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim(); const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim();
if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) { if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) {
return 'MakerWorld didn’t accept your auth token (it has probably expired). Paste a fresh one in Settings → MakerWorld auth token, then use “Download files from source”.'; return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true };
} }
return `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`; return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false };
} }
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License"). // Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").

View file

@ -30,6 +30,7 @@ const setupModal = document.getElementById('setupModal');
const editModal = document.getElementById('editModal'); const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal'); const fileDetailModal = document.getElementById('fileDetailModal');
const slicerModal = document.getElementById('slicerModal'); const slicerModal = document.getElementById('slicerModal');
const tokenModal = document.getElementById('tokenModal');
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadPrefs(); loadPrefs();
@ -767,6 +768,8 @@ function setupEventListeners() {
}); });
syncSearchMode(); syncSearchMode();
document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel); document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel);
document.getElementById('tokenForm').addEventListener('submit', handleTokenSubmit);
document.getElementById('cancelToken').addEventListener('click', () => closeModal(tokenModal));
document.getElementById('detailRenamed').addEventListener('click', (e) => { document.getElementById('detailRenamed').addEventListener('click', (e) => {
if (e.target.closest('#undoRenameBtn')) handleUndoRename(); if (e.target.closest('#undoRenameBtn')) handleUndoRename();
}); });
@ -2055,13 +2058,83 @@ async function handleDownloadFiles() {
try { try {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' }); const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (!res.ok && data.reason === 'makerworld_token') {
resetDownloadFilesBtn(id);
promptMakerworldToken(id, data.error);
return;
}
if (!res.ok) throw new Error(data.error); if (!res.ok) throw new Error(data.error);
await loadData(); await loadData();
openModelDetail(id); openModelDetail(id);
if (data.added === 0) alert('No new files were found (the source returned nothing new).'); if (data.added === 0) alert('No new files were found (the source returned nothing new).');
} catch (err) { } catch (err) {
alert('Download failed: ' + err.message); alert('Download failed: ' + err.message);
resetDownloadFilesBtn(id);
}
}
function resetDownloadFilesBtn(id) {
const btn = document.getElementById('downloadFilesBtn');
const model = findModel(id);
btn.disabled = false; btn.disabled = false;
btn.textContent = model && model.files.length ? 'Re-check source for files' : 'Download files from source';
}
// ---------------------------------------------------------------- MakerWorld token prompt
// MakerWorld tokens expire. When one is refused, ask for a fresh one right away and
// retry the download with it.
let tokenPromptModelId = null;
function promptMakerworldToken(modelId, reason) {
tokenPromptModelId = modelId;
document.getElementById('tokenReason').textContent = reason;
document.getElementById('tokenInput').value = '';
const status = document.getElementById('tokenStatus');
status.textContent = '';
status.classList.remove('is-error');
document.getElementById('tokenSubmit').disabled = false;
tokenModal.classList.add('active');
document.getElementById('tokenInput').focus();
}
async function handleTokenSubmit(e) {
e.preventDefault();
const token = document.getElementById('tokenInput').value.trim();
if (!token) return;
const id = tokenPromptModelId;
const status = document.getElementById('tokenStatus');
const submit = document.getElementById('tokenSubmit');
const fail = (text) => {
status.classList.add('is-error');
status.textContent = text;
submit.disabled = false;
};
submit.disabled = true;
status.classList.remove('is-error');
status.textContent = 'Saving your token and downloading the files… this can take a minute.';
try {
await postJson('/api/settings', { makerworld_token: token }, 'PUT');
} catch (err) {
fail(`Couldn’t save the token: ${err.message}`);
return;
}
try {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
fail(data.reason === 'makerworld_token'
? 'MakerWorld still didn’t accept that token. Check you copied the whole value of the token cookie while signed in, then try again.'
: `Your token is saved, but the download failed: ${data.error}`);
return;
}
status.textContent = data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.';
await loadData();
setTimeout(() => {
closeModal(tokenModal);
if (fileDetailModal.classList.contains('active') && fileDetailModal.dataset.currentModelId === String(id)) openModelDetail(id);
}, 900);
} catch (err) {
fail(`Your token is saved, but the download failed: ${err.message}`);
} }
} }
@ -2251,7 +2324,12 @@ async function handleImport(e) {
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
await loadData(); await loadData();
if (data.metadataOnly) { if (data.metadataOnly && data.filesErrorReason === 'makerworld_token') {
// Details imported, files refused: offer to fix the token and fetch them now.
closeModal(importModal);
openModelDetail(data.id);
promptMakerworldToken(data.id, `Imported “${data.name}”, but its files couldn’t be downloaded: ${data.filesError}`);
} else if (data.metadataOnly) {
status.textContent = data.filesError status.textContent = data.filesError
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}` ? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`
: data.metadataFetched : data.metadataFetched

View file

@ -549,6 +549,36 @@
</div> </div>
</div> </div>
<!-- MakerWorld token prompt (shown when MakerWorld refuses the saved token) -->
<div id="tokenModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Update your MakerWorld token</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="tokenForm">
<p id="tokenReason" class="token-reason"></p>
<ol class="token-steps">
<li>In this browser, sign in to <a href="https://makerworld.com" target="_blank" rel="noopener">makerworld.com</a>.</li>
<li>Open the developer tools (<kbd>⌥⌘I</kbd> on a Mac, <kbd>F12</kbd> elsewhere) and go to
Application (Storage in Firefox) → Cookies → https://makerworld.com.</li>
<li>Copy the value of the <code>token</code> cookie and paste it below.</li>
</ol>
<div class="form-group">
<label for="tokenInput">MakerWorld auth token</label>
<input type="password" id="tokenInput" autocomplete="off" spellcheck="false" required>
</div>
<div id="tokenStatus" class="form-hint token-status" role="status"></div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelToken">Not now</button>
<button type="submit" class="btn btn-primary" id="tokenSubmit">Save &amp; download files</button>
</div>
</form>
</div>
</div>
</div>
<!-- Slicer choice (per browser) --> <!-- Slicer choice (per browser) -->
<div id="slicerModal" class="modal"> <div id="slicerModal" class="modal">
<div class="modal-content"> <div class="modal-content">

View file

@ -447,3 +447,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.suggestion-actions .btn-sm { align-self: auto; } .suggestion-actions .btn-sm { align-self: auto; }
.suggestion-models { font-size: 13px; color: var(--text-muted); line-height: 1.7; } .suggestion-models { font-size: 13px; color: var(--text-muted); line-height: 1.7; }
.suggested-collections { font-size: 12px; color: var(--accent); } .suggested-collections { font-size: 12px; color: var(--accent); }
.token-reason { font-size: 14px; color: var(--text); line-height: 1.5; margin-bottom: 12px; }
.token-steps { margin: 0 0 16px 18px; font-size: 13px; color: var(--text-muted); line-height: 1.7; }
.token-steps code, .token-steps kbd { font-family: var(--mono); font-size: 12px; }
.token-status { min-height: 1em; }
.token-status.is-error { color: var(--danger); }

View file

@ -830,12 +830,14 @@ app.post('/api/import', async (req, res, next) => {
// Printables via its public GraphQL API), and capture the license. // Printables via its public GraphQL API), and capture the license.
let files = []; let files = [];
let filesError = null; // why no files could be downloaded, shown after the import let filesError = null; // why no files could be downloaded, shown after the import
let filesErrorReason = null; // makerworld_token: the app offers to update the token
let license = meta.license || null; let license = meta.license || null;
let commercial = null; let commercial = null;
if (detected.site === 'makerworld' && html) { if (detected.site === 'makerworld' && html) {
files = await downloadMakerworldFiles(html).catch((err) => { files = await downloadMakerworldFiles(html).catch((err) => {
console.warn('MakerWorld file download failed:', err.message); console.warn('MakerWorld file download failed:', err.message);
filesError = err.message; filesError = err.message;
filesErrorReason = err.reason || null;
return []; return [];
}); });
license = makerworld.parseLicense(html) || license; license = makerworld.parseLicense(html) || license;
@ -873,7 +875,7 @@ app.post('/api/import', async (req, res, next) => {
files, files,
}); });
fingerprint.kick(); fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched, filesError }); res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched, filesError, filesErrorReason });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -923,11 +925,16 @@ async function resolveCommercial(licenseName, fallback) {
// 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).
// MakerWorld downloads need both a FlareSolverr address and your auth token. // MakerWorld downloads need both a FlareSolverr address and your auth token.
// Errors carry a reason the app can act on: makerworld_token opens the token prompt.
async function requireMakerworldSettings() { async function requireMakerworldSettings() {
const { value: token } = await resolveSetting('makerworld_token'); const { value: token } = await resolveSetting('makerworld_token');
const { value: flareUrl } = await resolveSetting('flaresolverr_url'); const { value: flareUrl } = await resolveSetting('flaresolverr_url');
if (!token || !flareUrl) { if (!flareUrl) {
throw Object.assign(new Error('Add your MakerWorld auth token and FlareSolverr URL in Settings to download MakerWorld files.'), { status: 400 }); throw Object.assign(new Error('Add your FlareSolverr URL in Settings to download MakerWorld files (MakerWorld is behind Cloudflare).'),
{ status: 400, reason: 'makerworld_settings' });
}
if (!token) {
throw Object.assign(new Error('Add your MakerWorld auth token to download MakerWorld files.'), { status: 400, reason: 'makerworld_token' });
} }
return { token, flareUrl }; return { token, flareUrl };
} }
@ -952,12 +959,12 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
dl = await makerworld.resolveDownload(flareUrl, inst.id, token); dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
} catch (err) { } catch (err) {
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message); console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
problems.push(`Couldn’t ask MakerWorld for a download link: ${err.message}`); problems.push({ message: `Couldn’t ask MakerWorld for a download link: ${err.message}` });
continue; continue;
} }
const problem = makerworld.downloadProblem(dl); const problem = makerworld.downloadProblem(dl);
if (problem) { if (problem) {
console.warn(`MakerWorld instance ${inst.id}: ${problem}`); console.warn(`MakerWorld instance ${inst.id}: ${problem.message}`);
problems.push(problem); problems.push(problem);
continue; continue;
} }
@ -966,7 +973,7 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
const resp = await fetch(dl.url); const resp = await fetch(dl.url);
if (!resp.ok) { if (!resp.ok) {
console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); console.warn(`MakerWorld CDN download ${resp.status} for ${original}`);
problems.push(`Downloading ${original} from MakerWorld failed (HTTP ${resp.status})`); problems.push({ message: `Downloading ${original} from MakerWorld failed (HTTP ${resp.status})` });
continue; continue;
} }
const fileName = uniqueName(original); const fileName = uniqueName(original);
@ -974,7 +981,11 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer())); fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
files.push(describeFile(filePath, fileName, original)); files.push(describeFile(filePath, fileName, original));
} }
if (!files.length && problems.length) throw Object.assign(new Error(problems[0]), { status: 502 }); if (!files.length && problems.length) {
const refused = problems.find((p) => p.tokenRejected);
throw Object.assign(new Error((refused || problems[0]).message),
{ status: 502, reason: refused ? 'makerworld_token' : undefined });
}
return files; return files;
} }
@ -1358,7 +1369,7 @@ app.post('/api/autotag/reapply', async (req, res, next) => {
// Central error handler. // Central error handler.
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
if (!err.status) console.error('Request error:', err.message); if (!err.status) console.error('Request error:', err.message);
res.status(err.status || 500).json({ error: err.message }); res.status(err.status || 500).json({ error: err.message, ...(err.reason ? { reason: err.reason } : {}) });
}); });
db.init() db.init()

View file

@ -19,9 +19,12 @@ test('printable instances and the license are read from the page data', () => {
assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []); assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []);
}); });
test('a download answer without a link says why', () => { test('a download answer without a link says why, and flags a refused token', () => {
assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null); assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null);
assert.match(makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' }), /token \(it has probably expired\)/); const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' });
assert.equal(makerworld.downloadProblem({ code: 7, error: 'Model not found' }), 'MakerWorld didn’t give a download link: Model not found'); assert.equal(refused.tokenRejected, true);
assert.equal(makerworld.downloadProblem(null), 'MakerWorld didn’t give a download link'); assert.match(refused.message, /token \(it has probably expired\)/);
assert.deepEqual(makerworld.downloadProblem({ code: 7, error: 'Model not found' }),
{ message: 'MakerWorld didn’t give a download link: Model not found', tokenRejected: false });
assert.deepEqual(makerworld.downloadProblem(null), { message: 'MakerWorld didn’t give a download link', tokenRejected: false });
}); });