- 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>
46 lines
2 KiB
JavaScript
46 lines
2 KiB
JavaScript
// 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 };
|