Open models in your slicer and drag files out
- Settings → Slicer: pick Bambu Studio, OrcaSlicer, PrusaSlicer, Cura, a custom open-link template, or plain download (remembered per browser) - Bambu Studio / OrcaSlicer launch via their URL schemes with a 10-minute signed download link (/dl/<token>/<name>) that needs no session cookie - PrusaSlicer and Cura fall back to a download (they can't open our links) - 3MF projects report which app saved them; opening one in a different slicer asks first - "slicer" action on every file row and archive entry - Drag model cards (file, or zip of all files) and file/entry rows out to the desktop via Chromium's DownloadURL Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fbf1b706bf
commit
f3a69c82ec
11 changed files with 527 additions and 11 deletions
|
|
@ -14,6 +14,8 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
|||
- **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.
|
||||
- **Archives as folders** — ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background; browse what's inside and preview or download any file without unpacking. Models inside archives count for duplicate detection.
|
||||
- **Pictures, PDFs & readmes** — keep them with a model and view them in the app.
|
||||
- **Open in slicer** — pick your slicer once per browser (Settings → Slicer). Bambu Studio and OrcaSlicer open the file straight from a 10-minute download link (`bambustudioopen://…`, `orcaslicer://open?file=…`), so the server must be reachable at the address the browser uses; Bambu Studio asks before opening files that aren't from MakerWorld. PrusaSlicer (which only accepts Printables/Thingiverse links) and Cura get a normal download, and any other slicer with an open-link can be added as a template. Opening a 3MF project in a different slicer from the one that saved it asks first. Works for files inside archives too.
|
||||
- **Drag out** — drag a model card (its file, or a zip of all its files) or any file row, including files inside archives, onto your desktop or a folder to save it. Uses Chromium's DownloadURL drag, so it needs Chrome, Edge, Brave or similar.
|
||||
- **Auto thumbnails** made in the background: an embedded 3MF/plate preview, else a picture that came with the model (or one inside its archive), else a server-side render of the STL/OBJ/3MF; the browser renders FBX/STEP/3DM ones.
|
||||
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
|
||||
- **Collections** — group models (a model can be in many); rename/delete without losing models.
|
||||
|
|
|
|||
|
|
@ -73,6 +73,6 @@ function hashResetToken(token) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
|
||||
COOKIE, getSecret, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
|
||||
currentUser, requireAuth, makeResetToken, hashResetToken,
|
||||
};
|
||||
|
|
|
|||
46
lib/handoff.js
Normal file
46
lib/handoff.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Links for "Open in slicer". The slicer app downloads the file itself, outside
|
||||
// the browser and without the session cookie, so it gets a short-lived link that
|
||||
// grants just that one file.
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const TTL_SECONDS = 10 * 60;
|
||||
const AUDIENCE = 'printhive-handoff';
|
||||
const KINDS = ['file', 'entry'];
|
||||
// What slicers open: meshes, projects and STEP (Bambu Studio / OrcaSlicer import STEP).
|
||||
const SLICEABLE = ['3mf', 'stl', 'obj', 'step', 'stp'];
|
||||
|
||||
// Keyed off the session secret but never equal to it, so a hand-off link can't
|
||||
// pass as a login and a login can't pass as a hand-off link.
|
||||
const keyFor = (secret) => crypto.createHmac('sha256', secret).update('handoff').digest('hex');
|
||||
|
||||
function createToken(secret, { kind, id }, ttl = TTL_SECONDS) {
|
||||
if (!KINDS.includes(kind)) throw new Error(`Unknown hand-off kind: ${kind}`);
|
||||
return jwt.sign({ k: kind, id: Number(id) }, keyFor(secret), { audience: AUDIENCE, expiresIn: ttl, algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
// { kind, id }, or null for a link that's expired, altered or not a hand-off link.
|
||||
function readToken(secret, token) {
|
||||
try {
|
||||
const p = jwt.verify(String(token), keyFor(secret), { audience: AUDIENCE, algorithms: ['HS256'] });
|
||||
return KINDS.includes(p.k) && Number.isInteger(p.id) ? { kind: p.k, id: p.id } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Slicers name the download after the link's last path segment and decode the
|
||||
// link in different ways, so that segment is kept to plain ASCII.
|
||||
function urlName(name) {
|
||||
const ext = path.extname(String(name)).toLowerCase().replace(/[^\w.]/g, '');
|
||||
const base = path.basename(String(name), path.extname(String(name)))
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '') // accents split off by NFKD: "é" -> "e"
|
||||
.replace(/[^\w.-]+/g, '_')
|
||||
.replace(/^[_.-]+|[_.-]+$/g, '')
|
||||
.slice(0, 80);
|
||||
return `${base || 'model'}${ext}`;
|
||||
}
|
||||
|
||||
module.exports = { TTL_SECONDS, SLICEABLE, createToken, readToken, urlName };
|
||||
287
public/app.js
287
public/app.js
|
|
@ -10,7 +10,7 @@ let duplicateData = null; // last /api/duplicates response
|
|||
let duplicatePoll = null;
|
||||
|
||||
// View preferences, remembered per browser.
|
||||
const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '' };
|
||||
const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '' };
|
||||
const PREFS_KEY = 'printhive.prefs';
|
||||
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash'];
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ const settingsModal = document.getElementById('settingsModal');
|
|||
const setupModal = document.getElementById('setupModal');
|
||||
const editModal = document.getElementById('editModal');
|
||||
const fileDetailModal = document.getElementById('fileDetailModal');
|
||||
const slicerModal = document.getElementById('slicerModal');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadPrefs();
|
||||
|
|
@ -393,14 +394,14 @@ function emptyStateHtml() {
|
|||
}
|
||||
|
||||
function thumbHtml(m) {
|
||||
return m.thumbnailUrl ? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">` : '<span>📦</span>';
|
||||
return m.thumbnailUrl ? `<img src="${m.thumbnailUrl}" alt="" loading="lazy" draggable="false">` : '<span>📦</span>';
|
||||
}
|
||||
|
||||
function cardHtml(m) {
|
||||
const trashed = Boolean(m.deleted_at);
|
||||
const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added);
|
||||
return `
|
||||
<div class="file-card" data-model-id="${m.id}">
|
||||
<div class="file-card" data-model-id="${m.id}"${!trashed && m.files.length ? ' draggable="true" title="Drag out to save the file"' : ''}>
|
||||
<div class="file-thumbnail">${thumbHtml(m)}</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${escapeHtml(m.name)}</div>
|
||||
|
|
@ -739,7 +740,34 @@ function setupEventListeners() {
|
|||
item.plate = chip.dataset.plate === 'all' ? null : Number(chip.dataset.plate);
|
||||
loadViewer(item);
|
||||
});
|
||||
document.getElementById('openInSlicerBtn').addEventListener('click', () => {
|
||||
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
|
||||
if (item) openInSlicer(item);
|
||||
});
|
||||
document.getElementById('changeSlicerBtn').addEventListener('click', () => openSlicerModal());
|
||||
document.getElementById('chooseSlicerBtn').addEventListener('click', () => openSlicerModal());
|
||||
document.getElementById('slicerForm').addEventListener('submit', handleSaveSlicer);
|
||||
document.getElementById('slicerForm').addEventListener('change', syncSlicerForm);
|
||||
document.getElementById('cancelSlicer').addEventListener('click', () => closeModal(slicerModal));
|
||||
document.getElementById('slicerNotice').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-notice-action]');
|
||||
if (btn && noticeActions[btn.dataset.noticeAction]) noticeActions[btn.dataset.noticeAction]();
|
||||
});
|
||||
fileGrid.addEventListener('dragstart', (e) => {
|
||||
const card = e.target.closest('.file-card[draggable="true"]');
|
||||
const target = card && modelDragTarget(findModel(card.dataset.modelId));
|
||||
if (target) setDragOut(e.dataTransfer, target);
|
||||
});
|
||||
document.getElementById('detailFileInfo').addEventListener('dragstart', (e) => {
|
||||
const row = e.target.closest('[data-drag-url]');
|
||||
if (row) setDragOut(e.dataTransfer, { url: row.dataset.dragUrl, name: row.dataset.dragName });
|
||||
});
|
||||
document.getElementById('detailFileInfo').addEventListener('click', (e) => {
|
||||
const slice = e.target.closest('[data-slice-kind]');
|
||||
if (slice) {
|
||||
openInSlicer(handoffItem(slice.dataset.sliceKind, Number(slice.dataset.sliceId), slice.dataset.archive));
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest('[data-view-entry]');
|
||||
if (!btn) return;
|
||||
const archiveId = Number(btn.dataset.archive);
|
||||
|
|
@ -773,7 +801,9 @@ let currentDetailThumb = null; // model image, used as a viewer fallback
|
|||
|
||||
function closeModal(modal) {
|
||||
modal.classList.remove('active');
|
||||
if (modal === slicerModal) pendingHandoff = null;
|
||||
if (modal === fileDetailModal) {
|
||||
hideSlicerNotice();
|
||||
flushNotes();
|
||||
if (window.ModelViewer) window.ModelViewer.dispose();
|
||||
}
|
||||
|
|
@ -868,6 +898,7 @@ async function handleSkipSetup() {
|
|||
|
||||
async function openSettingsModal() {
|
||||
document.getElementById('settingsForm').reset();
|
||||
renderSettingsSlicer();
|
||||
document.getElementById('settingsStatus').textContent = '';
|
||||
try {
|
||||
const settings = await (await fetch('/api/settings')).json();
|
||||
|
|
@ -969,9 +1000,12 @@ function openModelDetail(modelId) {
|
|||
? `<a class="btn btn-secondary btn-sm download-all" href="/api/models/${model.id}/download">Download all ${model.files.length} files (.zip)</a>`
|
||||
: '';
|
||||
fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
|
||||
<div class="file-row">
|
||||
<div class="file-row" draggable="true" data-drag-url="/api/files/${f.id}/raw" data-drag-name="${escapeHtml(f.originalName)}">
|
||||
<span>${escapeHtml(f.originalName)} <em>(${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''})</em></span>
|
||||
<a href="/api/files/${f.id}/raw" download>download</a>
|
||||
<span class="row-actions">
|
||||
${sliceButtonHtml('file', f.id, f.format)}
|
||||
<a href="/api/files/${f.id}/raw" download draggable="false">download</a>
|
||||
</span>
|
||||
</div>
|
||||
${ARCHIVE_FORMATS.includes(f.format) ? `<div class="entry-list" data-entries-for="${f.id}"><div class="entry-head">Reading archive…</div></div>` : ''}`).join('');
|
||||
model.files.filter((f) => ARCHIVE_FORMATS.includes(f.format)).forEach((f) => loadArchiveEntries(model.id, f.id));
|
||||
|
|
@ -1012,7 +1046,9 @@ const archiveEntryCache = new Map(); // archive file id -> entries
|
|||
const itemKey = (item) => `${item.kind}:${item.id}`;
|
||||
|
||||
function renderViewerTabs(activeKey) {
|
||||
if (activeKey !== activeItemKey) hideSlicerNotice();
|
||||
activeItemKey = activeKey;
|
||||
renderSlicerBar();
|
||||
document.getElementById('detailFileTabs').innerHTML = detailItems.map((item) => `
|
||||
<button class="file-tab${itemKey(item) === activeKey ? ' active' : ''}" data-key="${itemKey(item)}">${escapeHtml(item.name)}</button>`).join('');
|
||||
}
|
||||
|
|
@ -1080,11 +1116,12 @@ async function loadArchiveEntries(modelId, fileId) {
|
|||
const name = e.path.split('/').pop();
|
||||
const folder = e.path.replace(/^\.\//, '').slice(0, -name.length);
|
||||
return `
|
||||
<div class="entry-row">
|
||||
<div class="entry-row" draggable="true" data-drag-url="/api/entries/${e.id}/raw" data-drag-name="${escapeHtml(name)}">
|
||||
<span title="${escapeHtml(e.path)}">${escapeHtml(name)}${folder ? `<span class="entry-folder">${escapeHtml(folder)}</span>` : ''} <em>${formatFileSize(e.size)}</em></span>
|
||||
<span class="row-actions">
|
||||
${PREVIEWABLE.includes(e.format) && e.format !== 'mtl' ? `<button type="button" class="link-btn" data-view-entry="${e.id}" data-archive="${fileId}">view</button>` : ''}
|
||||
<a href="/api/entries/${e.id}/raw" download>download</a>
|
||||
${sliceButtonHtml('entry', e.id, e.format, fileId)}
|
||||
<a href="/api/entries/${e.id}/raw" download draggable="false">download</a>
|
||||
</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
|
@ -1094,6 +1131,242 @@ async function loadArchiveEntries(modelId, fileId) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- slicer hand-off
|
||||
// A browser can't see which apps are installed, so the slicer is chosen once per
|
||||
// browser. Slicers that accept an open-link download the file from a short-lived
|
||||
// link; the others get an ordinary download to open from there.
|
||||
const SLICEABLE = ['3mf', 'stl', 'obj', 'step', 'stp'];
|
||||
const SLICERS = {
|
||||
bambu: {
|
||||
name: 'Bambu Studio', family: 'bambu', button: 'Open in Bambu Studio',
|
||||
blurb: 'Opens the file straight in Bambu Studio.',
|
||||
link: (url) => `bambustudioopen://${encodeURIComponent(url)}`,
|
||||
hint: 'Bambu Studio asks before opening files that aren’t from MakerWorld; choose Yes.',
|
||||
},
|
||||
orca: {
|
||||
name: 'OrcaSlicer', family: 'orca', button: 'Open in OrcaSlicer',
|
||||
blurb: 'Opens the file straight in OrcaSlicer.',
|
||||
link: (url) => `orcaslicer://open?file=${encodeURIComponent(url)}`,
|
||||
},
|
||||
prusa: {
|
||||
name: 'PrusaSlicer', family: 'prusa', button: 'Download for PrusaSlicer',
|
||||
blurb: 'Downloads the file. PrusaSlicer only opens links from Printables and Thingiverse.',
|
||||
link: null,
|
||||
},
|
||||
cura: {
|
||||
name: 'UltiMaker Cura', family: 'cura', button: 'Download for Cura',
|
||||
blurb: 'Downloads the file. Cura can’t be opened from a link.',
|
||||
link: null,
|
||||
},
|
||||
custom: {
|
||||
name: 'Another slicer', family: null, button: 'Open in slicer',
|
||||
blurb: 'Any slicer with its own open-link, using a template you enter.',
|
||||
link: (url) => (prefs.slicerTemplate || '').replaceAll('{url}', encodeURIComponent(url)).replaceAll('{rawurl}', url),
|
||||
},
|
||||
download: {
|
||||
name: 'Just download', family: null, button: 'Download file',
|
||||
blurb: 'Saves the file so you can open it in any app yourself.',
|
||||
link: null,
|
||||
},
|
||||
};
|
||||
const SLICER_FAMILIES = { bambu: 'Bambu Studio', orca: 'OrcaSlicer', prusa: 'PrusaSlicer', cura: 'Cura' };
|
||||
|
||||
// Which slicer saved a 3MF, from its Application metadata ("BambuStudio-02.01.01.52").
|
||||
function slicerFamily(application) {
|
||||
const app = String(application || '').toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (app.startsWith('bambustudio')) return 'bambu';
|
||||
if (app.startsWith('orcaslicer')) return 'orca';
|
||||
if (app.startsWith('prusaslicer')) return 'prusa';
|
||||
if (app.includes('cura')) return 'cura';
|
||||
return null;
|
||||
}
|
||||
|
||||
function validSlicerTemplate(template) {
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(template)
|
||||
&& !/^(javascript|data|vbscript|file|blob):/i.test(template)
|
||||
&& /\{(raw)?url\}/.test(template);
|
||||
}
|
||||
|
||||
function sliceButtonHtml(kind, id, format, archiveId = '') {
|
||||
if (!SLICEABLE.includes(format)) return '';
|
||||
return `<button type="button" class="link-btn" data-slice-kind="${kind}" data-slice-id="${id}" data-archive="${archiveId}" title="Open in your slicer">slicer</button>`;
|
||||
}
|
||||
|
||||
// The viewer item for a file or entry, so project info already fetched is reused.
|
||||
function handoffItem(kind, id, archiveId) {
|
||||
const existing = detailItems.find((i) => i.kind === kind && i.id === id);
|
||||
if (existing) return existing;
|
||||
if (kind === 'entry') {
|
||||
const entry = (archiveEntryCache.get(Number(archiveId)) || []).find((x) => x.id === id);
|
||||
return entry && { kind, id, format: entry.format, name: entry.path.split('/').pop(), archiveId: Number(archiveId) };
|
||||
}
|
||||
const model = findModel(fileDetailModal.dataset.currentModelId);
|
||||
const file = model && model.files.find((f) => f.id === id);
|
||||
return file && { kind, id, format: file.format, name: file.originalName };
|
||||
}
|
||||
|
||||
function renderSlicerBar() {
|
||||
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
|
||||
const bar = document.getElementById('slicerBar');
|
||||
bar.hidden = !(item && SLICEABLE.includes(item.format));
|
||||
const slicer = SLICERS[prefs.slicer];
|
||||
document.getElementById('openInSlicerBtn').textContent = slicer ? slicer.button : 'Open in slicer';
|
||||
document.getElementById('changeSlicerBtn').textContent = slicer ? 'change slicer' : 'choose slicer';
|
||||
}
|
||||
|
||||
function renderSettingsSlicer() {
|
||||
const slicer = SLICERS[prefs.slicer];
|
||||
document.getElementById('settingsSlicerName').textContent = slicer ? slicer.name : 'no slicer yet';
|
||||
}
|
||||
|
||||
let pendingHandoff = null; // the file to open once a slicer has been chosen
|
||||
let noticeActions = {};
|
||||
|
||||
function showSlicerNotice(text, actions = [], warning = false) {
|
||||
const el = document.getElementById('slicerNotice');
|
||||
noticeActions = {};
|
||||
el.innerHTML = `<div>${escapeHtml(text)}</div>` + (actions.length
|
||||
? `<div class="slicer-notice-actions">${actions.map((a, i) => {
|
||||
noticeActions[i] = a.run;
|
||||
return `<button type="button" class="btn ${a.primary ? 'btn-primary' : 'btn-secondary'} btn-sm" data-notice-action="${i}">${escapeHtml(a.label)}</button>`;
|
||||
}).join('')}</div>`
|
||||
: '');
|
||||
el.classList.toggle('is-warning', warning);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function hideSlicerNotice() {
|
||||
const el = document.getElementById('slicerNotice');
|
||||
el.hidden = true;
|
||||
el.innerHTML = '';
|
||||
noticeActions = {};
|
||||
}
|
||||
|
||||
// Follows a link without leaving the page: the OS hands custom schemes to their
|
||||
// app, and the file endpoints answer with a download.
|
||||
function followLink(href) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.hidden = true;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
const rawUrlFor = (item) => `/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}/raw`;
|
||||
|
||||
async function openInSlicer(item, { confirmed = false } = {}) {
|
||||
if (!item) return;
|
||||
const slicer = SLICERS[prefs.slicer];
|
||||
if (!slicer) {
|
||||
pendingHandoff = item;
|
||||
openSlicerModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Project 3MFs carry the slicer that saved them; moving one to another slicer
|
||||
// can lose plates, filaments or profiles, so ask first.
|
||||
if (item.format === '3mf' && slicer.family && !confirmed) {
|
||||
if (!item.project) {
|
||||
const started = Date.now();
|
||||
await loadProjectInfo(item);
|
||||
// Launching an app needs a recent click; after a slow read, ask for another.
|
||||
if (Date.now() - started > 1500) {
|
||||
showSlicerNotice(`${item.name} is ready.`, [{ label: slicer.button, primary: true, run: () => openInSlicer(item) }]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const saved = slicerFamily(item.project && item.project.application);
|
||||
if (saved && saved !== slicer.family) {
|
||||
showSlicerNotice(
|
||||
`${item.name} is a ${SLICER_FAMILIES[saved]} project. ${slicer.name} may not keep all of its settings (plates, filaments, print profiles).`,
|
||||
[
|
||||
{ label: `${slicer.button} anyway`, primary: true, run: () => openInSlicer(item, { confirmed: true }) },
|
||||
{ label: 'Download instead', run: () => { hideSlicerNotice(); followLink(rawUrlFor(item)); } },
|
||||
],
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!slicer.link) {
|
||||
followLink(rawUrlFor(item));
|
||||
showSlicerNotice(`Downloading ${item.name}${slicer.family ? ` — open it in ${slicer.name} from your downloads` : ''}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/handoff', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: item.kind, id: item.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
followLink(slicer.link(new URL(data.path, window.location.origin).href));
|
||||
showSlicerNotice(
|
||||
`Opening ${item.name} in ${slicer.family ? slicer.name : 'your slicer'}… ${slicer.hint ? `${slicer.hint} ` : ''}Nothing happening? Check the slicer is installed and up to date.`,
|
||||
[{ label: 'Download instead', run: () => { hideSlicerNotice(); followLink(rawUrlFor(item)); } }],
|
||||
);
|
||||
} catch (err) {
|
||||
showSlicerNotice(`Couldn’t open this in your slicer: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openSlicerModal() {
|
||||
const current = prefs.slicer || '';
|
||||
document.getElementById('slicerOptions').innerHTML = Object.entries(SLICERS).map(([key, s]) => `
|
||||
<label class="slicer-option">
|
||||
<input type="radio" name="slicer" value="${key}"${key === current ? ' checked' : ''}>
|
||||
<span><strong>${escapeHtml(s.name)}</strong><em>${escapeHtml(s.blurb)}</em></span>
|
||||
</label>`).join('');
|
||||
document.getElementById('slicerTemplate').value = prefs.slicerTemplate || '';
|
||||
document.getElementById('slicerFormError').textContent = '';
|
||||
syncSlicerForm();
|
||||
slicerModal.classList.add('active');
|
||||
}
|
||||
|
||||
function syncSlicerForm() {
|
||||
const choice = document.querySelector('#slicerOptions input:checked');
|
||||
document.getElementById('slicerTemplateGroup').hidden = !choice || choice.value !== 'custom';
|
||||
}
|
||||
|
||||
function handleSaveSlicer(e) {
|
||||
e.preventDefault();
|
||||
const choice = document.querySelector('#slicerOptions input:checked');
|
||||
const error = document.getElementById('slicerFormError');
|
||||
if (!choice) { error.textContent = 'Pick a slicer.'; return; }
|
||||
const template = document.getElementById('slicerTemplate').value.trim();
|
||||
if (choice.value === 'custom' && !validSlicerTemplate(template)) {
|
||||
error.textContent = 'Enter an app link containing {url}, like myslicer://open?file={url}.';
|
||||
return;
|
||||
}
|
||||
prefs.slicer = choice.value;
|
||||
if (choice.value === 'custom') prefs.slicerTemplate = template;
|
||||
savePrefs();
|
||||
const next = pendingHandoff;
|
||||
closeModal(slicerModal);
|
||||
renderSlicerBar();
|
||||
renderSettingsSlicer();
|
||||
if (next) openInSlicer(next); // still inside this click, so the slicer may launch
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drag-out
|
||||
// Chromium browsers save a dragged element as a file when it carries DownloadURL
|
||||
// ("type:name:url"); the download goes through the browser, session included.
|
||||
function setDragOut(dataTransfer, { url, name }) {
|
||||
dataTransfer.effectAllowed = 'copy';
|
||||
dataTransfer.setData('DownloadURL', `application/octet-stream:${String(name).replace(/[:\\/]/g, '-')}:${new URL(url, window.location.origin).href}`);
|
||||
}
|
||||
|
||||
// A model drags out as its only file, or as a zip of all of them.
|
||||
function modelDragTarget(model) {
|
||||
if (!model || !model.files.length) return null;
|
||||
if (model.files.length === 1) return { url: `/api/files/${model.files[0].id}/raw`, name: model.files[0].originalName };
|
||||
return { url: `/api/models/${model.id}/download`, name: `${(model.name || 'model').replace(/[^\w.-]+/g, '_')}.zip` };
|
||||
}
|
||||
|
||||
function renderDetailLicense(model) {
|
||||
const com = model.commercial_use; // true / false / null
|
||||
const badge = com === true
|
||||
|
|
|
|||
|
|
@ -219,6 +219,15 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="settingsForm">
|
||||
<h3 class="settings-group-title">Slicer</h3>
|
||||
<div class="form-group">
|
||||
<p class="form-hint">
|
||||
“Open in slicer” uses <strong id="settingsSlicerName">no slicer yet</strong>. This choice is
|
||||
remembered in this browser only, since each computer has its own slicers installed.
|
||||
</p>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="chooseSlicerBtn">Choose slicer…</button>
|
||||
</div>
|
||||
|
||||
<h3 class="settings-group-title">Import sources</h3>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -411,7 +420,14 @@
|
|||
<div id="detailViewer" class="model-viewer"></div>
|
||||
<div id="detailPlates" class="plate-chips" hidden></div>
|
||||
<div id="detailFileTabs" class="file-tabs"></div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
|
||||
<div class="preview-actions">
|
||||
<span id="slicerBar" class="slicer-bar" hidden>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="openInSlicerBtn">Open in slicer</button>
|
||||
<button type="button" class="link-btn slicer-change" id="changeSlicerBtn">change slicer</button>
|
||||
</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
|
||||
</div>
|
||||
<div id="slicerNotice" class="slicer-notice" role="status" hidden></div>
|
||||
</div>
|
||||
<div class="file-detail-info">
|
||||
<div class="info-section">
|
||||
|
|
@ -469,6 +485,35 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slicer choice (per browser) -->
|
||||
<div id="slicerModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Choose your slicer</h2>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="slicerForm">
|
||||
<p class="form-hint slicer-intro">Your browser can't tell which apps are installed, so pick the slicer this computer uses.</p>
|
||||
<div id="slicerOptions" class="slicer-options"></div>
|
||||
<div class="form-group" id="slicerTemplateGroup" hidden>
|
||||
<label for="slicerTemplate">Open-link template</label>
|
||||
<input type="text" id="slicerTemplate" placeholder="myslicer://open?file={url}" autocomplete="off" spellcheck="false">
|
||||
<p class="form-hint">
|
||||
<code>{url}</code> becomes the file's download link, URL-encoded (<code>{rawurl}</code> leaves it as is).
|
||||
The link works for 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<div id="slicerFormError" class="slicer-error"></div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelSlicer">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Model Modal -->
|
||||
<div id="editModal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
|
|
|||
|
|
@ -256,6 +256,26 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
|||
.file-row:last-child { border-bottom: none; }
|
||||
.file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; }
|
||||
.file-row > span:first-child { min-width: 0; overflow-wrap: anywhere; }
|
||||
.file-row[draggable="true"], .entry-row[draggable="true"] { cursor: grab; }
|
||||
|
||||
/* ---- slicer hand-off ---- */
|
||||
.preview-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
|
||||
.preview-actions .btn-sm { align-self: auto; }
|
||||
.slicer-bar { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.slicer-change { font-size: 12px; color: var(--text-dim); }
|
||||
.slicer-notice { font-size: 13px; line-height: 1.5; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; }
|
||||
.slicer-notice.is-warning { border-color: var(--accent); color: var(--text); }
|
||||
.slicer-notice-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
.slicer-notice-actions .btn-sm { align-self: auto; }
|
||||
.slicer-intro { margin: 0 0 14px; }
|
||||
.slicer-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||
.slicer-option { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; transition: border-color 0.12s, background 0.12s; }
|
||||
.slicer-option:hover { border-color: var(--border-strong); }
|
||||
.slicer-option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.slicer-option input { width: auto; margin-top: 3px; accent-color: var(--accent); flex-shrink: 0; }
|
||||
.slicer-option strong { display: block; font-size: 14px; font-weight: 500; color: var(--text); }
|
||||
.slicer-option em { display: block; font-style: normal; font-size: 12px; color: var(--text-dim); }
|
||||
.slicer-error { color: var(--danger); font-size: 13px; min-height: 1em; }
|
||||
|
||||
/* ---- import / settings ---- */
|
||||
.import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; }
|
||||
|
|
|
|||
49
server.js
49
server.js
|
|
@ -23,6 +23,7 @@ const fingerprint = require('./lib/fingerprint');
|
|||
const duplicates = require('./lib/duplicates');
|
||||
const archives = require('./lib/archives');
|
||||
const thumbnails = require('./lib/thumbnails');
|
||||
const handoff = require('./lib/handoff');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
|
@ -590,6 +591,54 @@ app.get('/api/entries/:id/raw', async (req, res, next) => {
|
|||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- slicer hand-off
|
||||
// "Open in slicer" launches the slicer with a link it downloads itself. The slicer
|
||||
// has no session cookie, so the link carries a short-lived grant for one file.
|
||||
async function handoffTarget(kind, id) {
|
||||
if (!Number.isInteger(Number(id))) return null;
|
||||
if (kind === 'file') {
|
||||
const file = await store.getFile(Number(id));
|
||||
return file && { name: file.original_name, format: file.format, resolve: async () => path.resolve(file.file_path) };
|
||||
}
|
||||
if (kind === 'entry') {
|
||||
const entry = await store.getArchiveEntry(Number(id));
|
||||
return entry && {
|
||||
name: path.posix.basename(entry.path), format: entry.format,
|
||||
resolve: () => archives.extractEntry(entry.archive, entry),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -> { path: "/dl/<token>/<name>", name, expiresIn }. The browser adds its own
|
||||
// origin, which is the address this machine already reaches the server on.
|
||||
app.post('/api/handoff', async (req, res, next) => {
|
||||
try {
|
||||
const { kind, id } = req.body || {};
|
||||
const target = await handoffTarget(kind, id);
|
||||
if (!target) return res.status(404).json({ error: 'File not found' });
|
||||
if (!handoff.SLICEABLE.includes(target.format)) {
|
||||
return res.status(415).json({ error: `.${target.format} files can't be opened in a slicer` });
|
||||
}
|
||||
const token = handoff.createToken(await auth.getSecret(), { kind, id: Number(id) });
|
||||
res.json({ path: `/dl/${token}/${handoff.urlName(target.name)}`, name: target.name, expiresIn: handoff.TTL_SECONDS });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Outside /api on purpose: the token is the only credential.
|
||||
app.get('/dl/:token/:name', async (req, res, next) => {
|
||||
try {
|
||||
const grant = handoff.readToken(await auth.getSecret(), req.params.token);
|
||||
if (!grant) return res.status(403).type('text/plain').send('This download link has expired. Open the model in Printhive again.');
|
||||
const target = await handoffTarget(grant.kind, grant.id);
|
||||
if (!target) return res.status(404).type('text/plain').send('File not found');
|
||||
const abs = await target.resolve();
|
||||
if (!fs.existsSync(abs)) return res.status(404).type('text/plain').send('File missing on disk');
|
||||
res.set({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
|
||||
res.download(abs, target.name);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Serve a 3MF as GLB, converting on first request (cached thereafter). This lets
|
||||
// the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox.
|
||||
// ?plate=N converts just that plate of a slicer project.
|
||||
|
|
|
|||
42
test/handoff.test.js
Normal file
42
test/handoff.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Short-lived "Open in slicer" download links (lib/handoff.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const handoff = require('../lib/handoff');
|
||||
|
||||
const SECRET = 'a'.repeat(96);
|
||||
|
||||
test('a link grants the file it was made for', () => {
|
||||
const token = handoff.createToken(SECRET, { kind: 'entry', id: '42' });
|
||||
assert.deepEqual(handoff.readToken(SECRET, token), { kind: 'entry', id: 42 });
|
||||
});
|
||||
|
||||
test('expired, altered or foreign links grant nothing', () => {
|
||||
const expired = handoff.createToken(SECRET, { kind: 'file', id: 1 }, -5);
|
||||
assert.equal(handoff.readToken(SECRET, expired), null);
|
||||
|
||||
const token = handoff.createToken(SECRET, { kind: 'file', id: 1 });
|
||||
const [head, , sig] = token.split('.');
|
||||
const otherBody = Buffer.from(JSON.stringify({ k: 'file', id: 2, aud: 'printhive-handoff', exp: 9999999999 })).toString('base64url');
|
||||
assert.equal(handoff.readToken(SECRET, `${head}.${otherBody}.${sig}`), null);
|
||||
assert.equal(handoff.readToken('b'.repeat(96), token), null);
|
||||
assert.equal(handoff.readToken(SECRET, 'not-a-token'), null);
|
||||
});
|
||||
|
||||
test('a login token is not a hand-off link', () => {
|
||||
const session = jwt.sign({ sub: 1, email: 'x@example.com' }, SECRET, { expiresIn: '30d' });
|
||||
assert.equal(handoff.readToken(SECRET, session), null);
|
||||
// …and a hand-off link doesn't verify as a login.
|
||||
assert.throws(() => jwt.verify(handoff.createToken(SECRET, { kind: 'file', id: 1 }), SECRET));
|
||||
});
|
||||
|
||||
test('only files and archive entries can be handed off', () => {
|
||||
assert.throws(() => handoff.createToken(SECRET, { kind: 'model', id: 1 }), /Unknown hand-off kind/);
|
||||
});
|
||||
|
||||
test('link file names stay plain ASCII and keep their extension', () => {
|
||||
assert.equal(handoff.urlName('Toy Keychain (v2).3mf'), 'Toy_Keychain_v2.3mf');
|
||||
assert.equal(handoff.urlName('Café déco.STL'), 'Cafe_deco.stl');
|
||||
assert.equal(handoff.urlName('日本.stl'), 'model.stl');
|
||||
assert.equal(handoff.urlName('../../etc/passwd.obj'), 'passwd.obj');
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ const fs = require('fs');
|
|||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
const AdmZip = require('adm-zip');
|
||||
const { PYTHON } = require('../lib/python');
|
||||
|
||||
const TOOLS = path.join(__dirname, '..', 'tools');
|
||||
|
|
@ -49,6 +50,21 @@ test('project info lists plates, their previews and the filaments', { skip }, ()
|
|||
assert.ok(!fs.existsSync(path.join(images, '2.png')));
|
||||
});
|
||||
|
||||
test('project info names the app that saved the 3MF', { skip }, () => {
|
||||
const info = (file) => lastJson(execFileSync(PYTHON, [path.join(TOOLS, 'threemf_info.py'), file], { encoding: 'utf8' }));
|
||||
const zip = new AdmZip(FIXTURE);
|
||||
const model = zip.readAsText('3D/3dmodel.model').replace(/\s*<metadata name="Application">[^<]*<\/metadata>/, '');
|
||||
const plain = path.join(tmp, 'plain.3mf');
|
||||
zip.updateFile('3D/3dmodel.model', Buffer.from(model));
|
||||
zip.writeZip(plain);
|
||||
assert.equal(info(plain).application, null);
|
||||
|
||||
const saved = path.join(tmp, 'saved.3mf');
|
||||
zip.updateFile('3D/3dmodel.model', Buffer.from(model.replace(/<model\b[^>]*>/, (m) => `${m}\n <metadata name="Application">BambuStudio-02.01.01.52</metadata>`)));
|
||||
zip.writeZip(saved);
|
||||
assert.equal(info(saved).application, 'BambuStudio-02.01.01.52');
|
||||
});
|
||||
|
||||
test('a plate converts alone, coloured by its part’s filament, without negative parts', { skip }, () => {
|
||||
const { summary, colours } = convert(1);
|
||||
assert.equal(summary.faces, 12);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ def main():
|
|||
return 1
|
||||
|
||||
print(json.dumps({
|
||||
"application": project["application"],
|
||||
"plates": [{"index": p["index"], "name": p["name"], "objectCount": len(p["objects"]),
|
||||
"thumbnail": bool(p["thumbnail"] or p["thumbnail_small"])} for p in project["plates"]],
|
||||
"filaments": project["filaments"],
|
||||
|
|
|
|||
|
|
@ -159,16 +159,38 @@ def load(path):
|
|||
return meshes, placements
|
||||
|
||||
|
||||
def _application(z, names):
|
||||
"""The app that wrote the file, from the root model's <metadata name="Application">.
|
||||
Metadata comes before the geometry, so reading stops at <resources>."""
|
||||
root = _root_part(z)
|
||||
if root not in names:
|
||||
return None
|
||||
try:
|
||||
with z.open(root) as fh:
|
||||
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
|
||||
name = _local(el.tag)
|
||||
if event == "start":
|
||||
if name in ("resources", "build"):
|
||||
break
|
||||
elif name == "metadata" and el.get("name") == "Application":
|
||||
return (el.text or "").strip() or None
|
||||
except etree.XMLSyntaxError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_project(path):
|
||||
"""Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent:
|
||||
|
||||
{plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
|
||||
{application: "BambuStudio-02.01.01.52" | None,
|
||||
plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
|
||||
filaments: [{colour, type}],
|
||||
objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}}
|
||||
"""
|
||||
project = {"plates": [], "filaments": [], "objects": {}}
|
||||
project = {"application": None, "plates": [], "filaments": [], "objects": {}}
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = set(z.namelist())
|
||||
project["application"] = _application(z, names)
|
||||
|
||||
if "Metadata/project_settings.config" in names:
|
||||
try:
|
||||
|
|
|
|||
Loading…
Reference in a new issue