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..576f0d4 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 `
-