Add email/password auth + SMTP password resets
All checks were successful
build-and-push / docker (push) Successful in 29s

- users + password_resets tables; bcrypt hashing; JWT session in an
  httpOnly cookie (auth_secret auto-generated in settings)
- /api gated behind auth (except /api/config and /api/auth/*)
- First-run creates the account (no open registration after that);
  login, logout, forgot/reset-password flows
- SMTP settings (host/port/secure/user/pass/from) in Settings menu;
  nodemailer sends reset links
- Auth screen (sign in / create account / forgot / reset) gates the SPA;
  Sign out button; COOKIE_SECURE=true in compose for HTTPS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-22 22:33:11 +10:00
parent b06767bd08
commit 5af88eb5ce
11 changed files with 617 additions and 6 deletions

View file

@ -2,6 +2,24 @@
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a -- A "model" is one logical download (a Thingiverse thing, a Printables model, a
-- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...). -- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...).
-- App users (email/password auth).
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One-time password-reset tokens (token stored hashed).
CREATE TABLE IF NOT EXISTS password_resets (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token_hash);
-- Simple key/value app settings (API tokens, site auth). Editable from the UI. -- Simple key/value app settings (API tokens, site auth). Editable from the UI.
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,

View file

@ -26,6 +26,7 @@ services:
environment: environment:
DATABASE_URL: postgres://printhive:${POSTGRES_PASSWORD:-printhive}@db:5432/printhive DATABASE_URL: postgres://printhive:${POSTGRES_PASSWORD:-printhive}@db:5432/printhive
PORT: 3000 PORT: 3000
COOKIE_SECURE: "true" # served over HTTPS via Traefik
# Optional — these can also be set in the app's Settings menu (stored in the DB). # Optional — these can also be set in the app's Settings menu (stored in the DB).
THINGIVERSE_TOKEN: ${THINGIVERSE_TOKEN:-} THINGIVERSE_TOKEN: ${THINGIVERSE_TOKEN:-}
FLARESOLVERR_URL: ${FLARESOLVERR_URL:-} FLARESOLVERR_URL: ${FLARESOLVERR_URL:-}

78
lib/auth.js Normal file
View file

@ -0,0 +1,78 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const store = require('./store');
const COOKIE = 'ph_session';
const TOKEN_TTL = '30d';
// JWT signing secret — generated once and persisted in settings.
let cachedSecret = null;
async function getSecret() {
if (cachedSecret) return cachedSecret;
let secret = await store.getSetting('auth_secret');
if (!secret) {
secret = crypto.randomBytes(48).toString('hex');
await store.setSetting('auth_secret', secret);
}
cachedSecret = secret;
return secret;
}
function hashPassword(password) {
return bcrypt.hash(password, 12);
}
function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
async function issueToken(user) {
return jwt.sign({ sub: user.id, email: user.email }, await getSecret(), { expiresIn: TOKEN_TTL });
}
function setAuthCookie(res, token) {
res.cookie(COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.COOKIE_SECURE === 'true',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
function clearAuthCookie(res) {
res.clearCookie(COOKIE);
}
// Resolve the current user from the session cookie, or null.
async function currentUser(req) {
const token = req.cookies && req.cookies[COOKIE];
if (!token) return null;
try {
const payload = jwt.verify(token, await getSecret());
return store.getUserById(payload.sub);
} catch {
return null;
}
}
// Express middleware: 401 unless authenticated.
async function requireAuth(req, res, next) {
const user = await currentUser(req);
if (!user) return res.status(401).json({ error: 'Authentication required' });
req.user = user;
next();
}
// Password-reset tokens: return { token, hash } — store the hash, email the token.
function makeResetToken() {
const token = crypto.randomBytes(32).toString('hex');
const hash = crypto.createHash('sha256').update(token).digest('hex');
return { token, hash };
}
function hashResetToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
module.exports = {
COOKIE, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
currentUser, requireAuth, makeResetToken, hashResetToken,
};

30
lib/mailer.js Normal file
View file

@ -0,0 +1,30 @@
const nodemailer = require('nodemailer');
// Build a transport from the resolved SMTP settings, or null if not configured.
function buildTransport(smtp) {
if (!smtp.host) return null;
const port = Number(smtp.port) || 587;
return nodemailer.createTransport({
host: smtp.host,
port,
secure: smtp.secure === true || smtp.secure === 'true' || port === 465,
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
});
}
// smtp: { host, port, secure, user, pass, from }
async function sendPasswordReset(smtp, to, link) {
const transport = buildTransport(smtp);
if (!transport) throw new Error('SMTP is not configured.');
await transport.sendMail({
from: smtp.from || smtp.user,
to,
subject: 'Printhive password reset',
text: `Reset your Printhive password using this link (valid for 1 hour):\n\n${link}\n\nIf you didn't request this, you can ignore this email.`,
html: `<p>Reset your Printhive password using this link (valid for 1 hour):</p>
<p><a href="${link}">${link}</a></p>
<p style="color:#888">If you didn't request this, you can ignore this email.</p>`,
});
}
module.exports = { buildTransport, sendPasswordReset };

View file

@ -208,6 +208,50 @@ async function deleteProject(id) {
return rowCount > 0; return rowCount > 0;
} }
// ---------------------------------------------------------------- users / auth
async function countUsers() {
const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users');
return rows[0].n;
}
async function getUserByEmail(email) {
const { rows } = await db.query('SELECT * FROM users WHERE lower(email) = lower($1)', [email]);
return rows[0] || null;
}
async function getUserById(id) {
const { rows } = await db.query('SELECT id, email, created_at FROM users WHERE id = $1', [id]);
return rows[0] || null;
}
async function createUser(email, passwordHash) {
const { rows } = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, created_at',
[email.trim(), passwordHash]);
return rows[0];
}
async function updateUserPassword(id, passwordHash) {
await db.query('UPDATE users SET password_hash = $2 WHERE id = $1', [id, passwordHash]);
}
async function createPasswordReset(userId, tokenHash, expiresAt) {
await db.query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1,$2,$3)',
[userId, tokenHash, expiresAt]);
}
async function getValidReset(tokenHash) {
const { rows } = await db.query(
`SELECT * FROM password_resets
WHERE token_hash = $1 AND used = false AND expires_at > now()`, [tokenHash]);
return rows[0] || null;
}
async function markResetUsed(id) {
await db.query('UPDATE password_resets SET used = true WHERE id = $1', [id]);
}
// ---------------------------------------------------------------- settings // ---------------------------------------------------------------- settings
async function getSetting(key) { async function getSetting(key) {
const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]); const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]);
@ -241,4 +285,6 @@ module.exports = {
getAllTags, getAllTags,
getAllProjects, getProject, createProject, updateProject, deleteProject, getAllProjects, getProject, createProject, updateProject, deleteProject,
getSetting, setSetting, getSetting, setSetting,
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
createPasswordReset, getValidReset, markResetUsed,
}; };

152
package-lock.json generated
View file

@ -1,19 +1,23 @@
{ {
"name": "stl-library-manager", "name": "printhive",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "stl-library-manager", "name": "printhive",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0", "node-stl": "^0.7.0",
"nodemailer": "^9.0.1",
"pg": "^8.22.0" "pg": "^8.22.0"
}, },
"devDependencies": { "devDependencies": {
@ -78,6 +82,15 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -141,6 +154,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -266,6 +285,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@ -332,6 +370,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -713,6 +760,97 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -859,6 +997,15 @@
"integrity": "sha512-ZdqkD7vE7VskSOVgj6p9z9G5pYEXIAq53Vw276kCPdZyimintgxqwpbdwPAxspgJIvsrno3q8jB8g7UDwNREGg==", "integrity": "sha512-ZdqkD7vE7VskSOVgj6p9z9G5pYEXIAq53Vw276kCPdZyimintgxqwpbdwPAxspgJIvsrno3q8jB8g7UDwNREGg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@ -1241,7 +1388,6 @@
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"

View file

@ -17,10 +17,14 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0", "node-stl": "^0.7.0",
"nodemailer": "^9.0.1",
"pg": "^8.22.0" "pg": "^8.22.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -20,10 +20,106 @@ const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal'); const fileDetailModal = document.getElementById('fileDetailModal');
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadData(); setupAuthListeners();
setupEventListeners(); bootstrap();
}); });
// Decide what to show: the app (if signed in), the reset form (if ?reset=...),
// or the sign-in / create-account screen.
async function bootstrap() {
const resetToken = new URLSearchParams(location.search).get('reset');
if (resetToken) { showAuth('reset'); return; }
let status;
try {
status = await (await fetch('/api/auth/status')).json();
} catch {
status = { authenticated: false, needsSetup: false };
}
if (status.authenticated) startApp();
else showAuth(status.needsSetup ? 'register' : 'login');
}
let appStarted = false;
function startApp() {
document.getElementById('authScreen').style.display = 'none';
document.querySelector('.container').style.display = '';
if (!appStarted) { setupEventListeners(); appStarted = true; }
loadData();
}
function showAuth(mode) {
document.querySelector('.container').style.display = 'none';
document.getElementById('authScreen').style.display = 'flex';
['login', 'register', 'forgot', 'reset'].forEach((m) => {
document.getElementById('auth-' + m).style.display = m === mode ? '' : 'none';
});
document.getElementById('authError').textContent = '';
}
function setupAuthListeners() {
document.getElementById('auth-login').addEventListener('submit', handleLogin);
document.getElementById('auth-register').addEventListener('submit', handleRegister);
document.getElementById('auth-forgot').addEventListener('submit', handleForgot);
document.getElementById('auth-reset').addEventListener('submit', handleReset);
document.querySelectorAll('.auth-nav').forEach((b) => {
b.addEventListener('click', () => showAuth(b.dataset.auth));
});
}
async function authPost(url, body) {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Something went wrong.');
return data;
}
function authError(msg) { document.getElementById('authError').textContent = msg; }
async function handleLogin(e) {
e.preventDefault();
try {
await authPost('/api/auth/login', {
email: document.getElementById('loginEmail').value,
password: document.getElementById('loginPassword').value,
});
location.reload();
} catch (err) { authError(err.message); }
}
async function handleRegister(e) {
e.preventDefault();
try {
await authPost('/api/auth/register', {
email: document.getElementById('regEmail').value,
password: document.getElementById('regPassword').value,
});
location.reload();
} catch (err) { authError(err.message); }
}
async function handleForgot(e) {
e.preventDefault();
try {
await authPost('/api/auth/forgot', { email: document.getElementById('forgotEmail').value });
authError('If that email has an account and email is configured, a reset link is on its way.');
} catch (err) { authError(err.message); }
}
async function handleReset(e) {
e.preventDefault();
const token = new URLSearchParams(location.search).get('reset');
try {
await authPost('/api/auth/reset', { token, password: document.getElementById('resetPassword').value });
window.history.replaceState({}, '', location.pathname); // drop ?reset=
showAuth('login');
authError('Password updated — you can sign in now.');
} catch (err) { authError(err.message); }
}
async function handleLogout() {
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ }
location.reload();
}
async function loadData() { async function loadData() {
try { try {
const [modelsRes, projectsRes, configRes] = await Promise.all([ const [modelsRes, projectsRes, configRes] = await Promise.all([
@ -166,6 +262,7 @@ function setupEventListeners() {
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal); document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
document.getElementById('importBtn').addEventListener('click', openImportModal); document.getElementById('importBtn').addEventListener('click', openImportModal);
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal); document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
document.getElementById('logoutBtn').addEventListener('click', handleLogout);
document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings); document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings);
document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal)); document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal));
document.querySelectorAll('.clear-setting').forEach((btn) => { document.querySelectorAll('.clear-setting').forEach((btn) => {

View file

@ -15,7 +15,52 @@
</script> </script>
</head> </head>
<body> <body>
<div class="container"> <!-- Auth screen (shown when signed out / first run / password reset) -->
<div id="authScreen" class="auth-screen" style="display:none">
<div class="auth-card">
<h1 class="brand auth-brand">
<svg class="logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 2.5 20.5 7v10L12 21.5 3.5 17V7L12 2.5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M12 7.5 16.3 10v4L12 16.5 7.7 14v-4L12 7.5Z" fill="currentColor" opacity="0.85"/>
</svg>
<span>Print<span class="accent">hive</span></span>
</h1>
<form id="auth-login" class="auth-form">
<h2>Sign in</h2>
<input type="email" id="loginEmail" placeholder="Email" autocomplete="username" required>
<input type="password" id="loginPassword" placeholder="Password" autocomplete="current-password" required>
<button type="submit" class="btn btn-primary">Sign in</button>
<button type="button" class="btn btn-link auth-nav" data-auth="forgot">Forgot password?</button>
</form>
<form id="auth-register" class="auth-form">
<h2>Create your account</h2>
<p class="form-hint">First-time setup — this becomes your login.</p>
<input type="email" id="regEmail" placeholder="Email" autocomplete="username" required>
<input type="password" id="regPassword" placeholder="Password (min 8 characters)" autocomplete="new-password" required>
<button type="submit" class="btn btn-primary">Create account</button>
</form>
<form id="auth-forgot" class="auth-form">
<h2>Reset password</h2>
<p class="form-hint">Enter your email and we'll send a reset link (if email is configured).</p>
<input type="email" id="forgotEmail" placeholder="Email" required>
<button type="submit" class="btn btn-primary">Send reset link</button>
<button type="button" class="btn btn-link auth-nav" data-auth="login">Back to sign in</button>
</form>
<form id="auth-reset" class="auth-form">
<h2>Set a new password</h2>
<input type="password" id="resetPassword" placeholder="New password (min 8 characters)" autocomplete="new-password" required>
<button type="submit" class="btn btn-primary">Set new password</button>
</form>
<div id="authError" class="auth-error"></div>
</div>
</div>
<div class="container" style="display:none">
<header> <header>
<h1 class="brand"> <h1 class="brand">
<svg class="logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <svg class="logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
@ -25,6 +70,7 @@
<span>Print<span class="accent">hive</span></span> <span>Print<span class="accent">hive</span></span>
</h1> </h1>
<div class="header-actions"> <div class="header-actions">
<button id="logoutBtn" class="btn btn-secondary">Sign out</button>
<button id="settingsBtn" class="btn btn-secondary">Settings</button> <button id="settingsBtn" class="btn btn-secondary">Settings</button>
<button id="newProjectBtn" class="btn btn-secondary">New Collection</button> <button id="newProjectBtn" class="btn btn-secondary">New Collection</button>
<button id="importBtn" class="btn btn-secondary">Import from URL</button> <button id="importBtn" class="btn btn-secondary">Import from URL</button>
@ -173,6 +219,17 @@
<button type="button" class="btn btn-link clear-setting" data-setting="printables_token">Clear token</button> <button type="button" class="btn btn-link clear-setting" data-setting="printables_token">Clear token</button>
</div> </div>
<h3 class="settings-group-title">Email (SMTP) — for password resets</h3>
<div class="print-settings">
<input type="text" data-setting="smtp_host" placeholder="SMTP host" autocomplete="off">
<input type="text" data-setting="smtp_port" placeholder="Port (e.g. 587)" autocomplete="off">
<input type="text" data-setting="smtp_secure" placeholder="SSL: true/false" autocomplete="off">
<input type="text" data-setting="smtp_from" placeholder="From address" autocomplete="off">
<input type="text" data-setting="smtp_user" placeholder="Username" autocomplete="off">
<input type="password" data-setting="smtp_pass" placeholder="Password" autocomplete="off">
</div>
<p class="form-hint">Status: <span class="setting-status" data-status="smtp_host"></span>. Needed for "forgot password" emails.</p>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>

View file

@ -242,3 +242,23 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.main-content { grid-template-columns: 1fr; } .main-content { grid-template-columns: 1fr; }
.file-detail-content { grid-template-columns: 1fr; } .file-detail-content { grid-template-columns: 1fr; }
} }
/* ---- auth screen ---- */
.auth-screen {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
background: var(--bg); padding: 20px;
}
.auth-card {
width: 100%; max-width: 380px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 32px 28px;
}
.auth-brand { justify-content: center; font-size: 24px; margin-bottom: 24px; }
.auth-form { display: flex; flex-direction: column; gap: 12px; }
.auth-form h2 { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
.auth-form input { width: 100%; }
.auth-form .btn-primary { margin-top: 4px; }
.auth-form .btn-link { color: var(--text-muted); align-self: center; }
.auth-form .btn-link:hover { color: var(--accent); }
.auth-error { margin-top: 14px; color: var(--text-muted); font-size: 13px; text-align: center; min-height: 18px; }

114
server.js
View file

@ -16,15 +16,27 @@ const makerworld = require('./lib/importers/makerworld');
const printables = require('./lib/importers/printables'); const printables = require('./lib/importers/printables');
const converter = require('./lib/converter'); const converter = require('./lib/converter');
const licenses = require('./lib/licenses'); const licenses = require('./lib/licenses');
const cookieParser = require('cookie-parser');
const auth = require('./lib/auth');
const mailer = require('./lib/mailer');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const UPLOAD_DIR = './uploads'; const UPLOAD_DIR = './uploads';
app.set('trust proxy', 1); // behind Traefik — trust X-Forwarded-* for protocol/cookies
app.use(express.json({ limit: '8mb' })); // generous for base64 thumbnail PNGs app.use(express.json({ limit: '8mb' })); // generous for base64 thumbnail PNGs
app.use(cookieParser());
app.use(express.static('public')); app.use(express.static('public'));
app.use('/thumbnails', express.static('thumbnails')); app.use('/thumbnails', express.static('thumbnails'));
// Gate the API: everything under /api requires auth except the bootstrap config
// and the auth endpoints themselves.
app.use('/api', (req, res, next) => {
if (req.path === '/config' || req.path.startsWith('/auth/')) return next();
return auth.requireAuth(req, res, next);
});
// ----------------------------------------------------------------- helpers // ----------------------------------------------------------------- helpers
function ensureUploadDir() { function ensureUploadDir() {
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
@ -79,6 +91,12 @@ const SETTINGS = {
flaresolverr_url: { env: 'FLARESOLVERR_URL', label: 'FlareSolverr URL', secret: false }, flaresolverr_url: { env: 'FLARESOLVERR_URL', label: 'FlareSolverr URL', secret: false },
makerworld_token: { env: 'MAKERWORLD_TOKEN', label: 'MakerWorld auth token', secret: true }, makerworld_token: { env: 'MAKERWORLD_TOKEN', label: 'MakerWorld auth token', secret: true },
printables_token: { env: 'PRINTABLES_TOKEN', label: 'Printables auth token', secret: true }, printables_token: { env: 'PRINTABLES_TOKEN', label: 'Printables auth token', secret: true },
smtp_host: { env: 'SMTP_HOST', label: 'SMTP host', secret: false },
smtp_port: { env: 'SMTP_PORT', label: 'SMTP port', secret: false },
smtp_secure: { env: 'SMTP_SECURE', label: 'SMTP SSL (true/false)', secret: false },
smtp_user: { env: 'SMTP_USER', label: 'SMTP username', secret: false },
smtp_pass: { env: 'SMTP_PASS', label: 'SMTP password', secret: true },
smtp_from: { env: 'SMTP_FROM', label: 'From address', secret: false },
}; };
async function resolveSetting(key) { async function resolveSetting(key) {
@ -131,6 +149,102 @@ app.get('/api/settings', async (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// ----------------------------------------------------------------- auth
async function resolveSmtp() {
return {
host: (await resolveSetting('smtp_host')).value,
port: (await resolveSetting('smtp_port')).value,
secure: (await resolveSetting('smtp_secure')).value,
user: (await resolveSetting('smtp_user')).value,
pass: (await resolveSetting('smtp_pass')).value,
from: (await resolveSetting('smtp_from')).value,
};
}
// Public: tells the login UI whether to show "create account" vs "sign in".
app.get('/api/auth/status', async (req, res, next) => {
try {
const needsSetup = (await store.countUsers()) === 0;
const user = await auth.currentUser(req);
const smtp = await resolveSmtp();
res.json({
needsSetup,
authenticated: Boolean(user),
user: user ? { id: user.id, email: user.email } : null,
smtpConfigured: Boolean(smtp.host),
});
} catch (e) { next(e); }
});
app.get('/api/auth/me', auth.requireAuth, (req, res) => {
res.json({ user: { id: req.user.id, email: req.user.email } });
});
// First-run account creation (only allowed while there are no users).
app.post('/api/auth/register', async (req, res, next) => {
try {
if ((await store.countUsers()) > 0) return res.status(403).json({ error: 'Registration is closed.' });
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'Email and password are required.' });
if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
const user = await store.createUser(email, await auth.hashPassword(password));
auth.setAuthCookie(res, await auth.issueToken(user));
res.status(201).json({ user: { id: user.id, email: user.email } });
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'That email is already registered.' });
next(e);
}
});
app.post('/api/auth/login', async (req, res, next) => {
try {
const { email, password } = req.body || {};
const user = email ? await store.getUserByEmail(email) : null;
if (!user || !(await auth.verifyPassword(password || '', user.password_hash))) {
return res.status(401).json({ error: 'Invalid email or password.' });
}
auth.setAuthCookie(res, await auth.issueToken(user));
res.json({ user: { id: user.id, email: user.email } });
} catch (e) { next(e); }
});
app.post('/api/auth/logout', (req, res) => {
auth.clearAuthCookie(res);
res.json({ ok: true });
});
// Always responds ok (don't reveal whether an email exists).
app.post('/api/auth/forgot', async (req, res, next) => {
try {
const { email } = req.body || {};
const user = email ? await store.getUserByEmail(email) : null;
if (user) {
const smtp = await resolveSmtp();
if (smtp.host) {
const { token, hash } = auth.makeResetToken();
await store.createPasswordReset(user.id, hash, new Date(Date.now() + 3600 * 1000));
const base = `${req.protocol}://${req.get('host')}`;
await mailer.sendPasswordReset(smtp, user.email, `${base}/?reset=${token}`)
.catch((err) => console.error('Reset email failed:', err.message));
}
}
res.json({ ok: true });
} catch (e) { next(e); }
});
app.post('/api/auth/reset', async (req, res, next) => {
try {
const { token, password } = req.body || {};
if (!token || !password) return res.status(400).json({ error: 'Token and new password are required.' });
if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
const reset = await store.getValidReset(auth.hashResetToken(token));
if (!reset) return res.status(400).json({ error: 'This reset link is invalid or has expired.' });
await store.updateUserPassword(reset.user_id, await auth.hashPassword(password));
await store.markResetUsed(reset.id);
res.json({ ok: true });
} catch (e) { next(e); }
});
// Mark the first-run setup wizard as done (so it won't show again). // Mark the first-run setup wizard as done (so it won't show again).
app.post('/api/setup/complete', async (req, res, next) => { app.post('/api/setup/complete', async (req, res, next) => {
try { try {