diff --git a/README.md b/README.md index 7f7c104..9fd3ad1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/auth.js b/lib/auth.js index 574a6f0..d517bcf 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -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, }; diff --git a/lib/handoff.js b/lib/handoff.js new file mode 100644 index 0000000..d23db07 --- /dev/null +++ b/lib/handoff.js @@ -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 }; diff --git a/public/app.js b/public/app.js index 70e3cb8..18dd519 100644 --- a/public/app.js +++ b/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 ? `` : '📦'; + return m.thumbnailUrl ? `` : '📦'; } function cardHtml(m) { const trashed = Boolean(m.deleted_at); const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added); return ` -
+
${thumbHtml(m)}
${escapeHtml(m.name)}
@@ -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) { ? `Download all ${model.files.length} files (.zip)` : ''; fileInfoEl.innerHTML = downloadAll + model.files.map((f) => ` -
+
${escapeHtml(f.originalName)} (${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''}) - download + + ${sliceButtonHtml('file', f.id, f.format)} + download +
${ARCHIVE_FORMATS.includes(f.format) ? `
Reading archive…
` : ''}`).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) => ` `).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 ` -
+
${escapeHtml(name)}${folder ? `${escapeHtml(folder)}` : ''} ${formatFileSize(e.size)} ${PREVIEWABLE.includes(e.format) && e.format !== 'mtl' ? `` : ''} - download + ${sliceButtonHtml('entry', e.id, e.format, fileId)} + download
`; }).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 ``; +} + +// 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 = `
${escapeHtml(text)}
` + (actions.length + ? `
${actions.map((a, i) => { + noticeActions[i] = a.run; + return ``; + }).join('')}
` + : ''); + 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]) => ` + `).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 diff --git a/public/index.html b/public/index.html index bbf2369..b2f7d25 100644 --- a/public/index.html +++ b/public/index.html @@ -219,6 +219,15 @@