Add email/password auth + SMTP password resets
All checks were successful
build-and-push / docker (push) Successful in 29s
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:
parent
b06767bd08
commit
5af88eb5ce
11 changed files with 617 additions and 6 deletions
|
|
@ -2,6 +2,24 @@
|
|||
-- 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/...).
|
||||
|
||||
-- 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.
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: postgres://printhive:${POSTGRES_PASSWORD:-printhive}@db:5432/printhive
|
||||
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).
|
||||
THINGIVERSE_TOKEN: ${THINGIVERSE_TOKEN:-}
|
||||
FLARESOLVERR_URL: ${FLARESOLVERR_URL:-}
|
||||
|
|
|
|||
78
lib/auth.js
Normal file
78
lib/auth.js
Normal 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
30
lib/mailer.js
Normal 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 };
|
||||
46
lib/store.js
46
lib/store.js
|
|
@ -208,6 +208,50 @@ async function deleteProject(id) {
|
|||
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
|
||||
async function getSetting(key) {
|
||||
const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||||
|
|
@ -241,4 +285,6 @@ module.exports = {
|
|||
getAllTags,
|
||||
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
||||
getSetting, setSetting,
|
||||
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
||||
createPasswordReset, getValidReset, markResetUsed,
|
||||
};
|
||||
|
|
|
|||
152
package-lock.json
generated
152
package-lock.json
generated
|
|
@ -1,19 +1,23 @@
|
|||
{
|
||||
"name": "stl-library-manager",
|
||||
"name": "printhive",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stl-library-manager",
|
||||
"name": "printhive",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-stl": "^0.7.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -78,6 +82,15 @@
|
|||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
|
@ -141,6 +154,12 @@
|
|||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
|
|
@ -266,6 +285,25 @@
|
|||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
|
|
@ -332,6 +370,15 @@
|
|||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
|
|
@ -713,6 +760,97 @@
|
|||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -859,6 +997,15 @@
|
|||
"integrity": "sha512-ZdqkD7vE7VskSOVgj6p9z9G5pYEXIAq53Vw276kCPdZyimintgxqwpbdwPAxspgJIvsrno3q8jB8g7UDwNREGg==",
|
||||
"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": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
|
|
@ -1241,7 +1388,6 @@
|
|||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-stl": "^0.7.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
101
public/app.js
101
public/app.js
|
|
@ -20,10 +20,106 @@ const editModal = document.getElementById('editModal');
|
|||
const fileDetailModal = document.getElementById('fileDetailModal');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadData();
|
||||
setupEventListeners();
|
||||
setupAuthListeners();
|
||||
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() {
|
||||
try {
|
||||
const [modelsRes, projectsRes, configRes] = await Promise.all([
|
||||
|
|
@ -166,6 +262,7 @@ function setupEventListeners() {
|
|||
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
|
||||
document.getElementById('importBtn').addEventListener('click', openImportModal);
|
||||
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
|
||||
document.getElementById('logoutBtn').addEventListener('click', handleLogout);
|
||||
document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings);
|
||||
document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal));
|
||||
document.querySelectorAll('.clear-setting').forEach((btn) => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,52 @@
|
|||
</script>
|
||||
</head>
|
||||
<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>
|
||||
<h1 class="brand">
|
||||
<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>
|
||||
</h1>
|
||||
<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="newProjectBtn" class="btn btn-secondary">New Collection</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>
|
||||
</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">
|
||||
<button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
|
|
|
|||
|
|
@ -242,3 +242,23 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
|||
.main-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
114
server.js
|
|
@ -16,15 +16,27 @@ const makerworld = require('./lib/importers/makerworld');
|
|||
const printables = require('./lib/importers/printables');
|
||||
const converter = require('./lib/converter');
|
||||
const licenses = require('./lib/licenses');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const auth = require('./lib/auth');
|
||||
const mailer = require('./lib/mailer');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
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(cookieParser());
|
||||
app.use(express.static('public'));
|
||||
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
|
||||
function ensureUploadDir() {
|
||||
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 },
|
||||
makerworld_token: { env: 'MAKERWORLD_TOKEN', label: 'MakerWorld 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) {
|
||||
|
|
@ -131,6 +149,102 @@ app.get('/api/settings', async (req, res, next) => {
|
|||
} 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).
|
||||
app.post('/api/setup/complete', async (req, res, next) => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue