BillTracker/routes/profile.cts

451 lines
15 KiB
TypeScript
Raw Permalink Normal View History

import type { Req, Res, Next } from '../types/http';
('use strict');
2026-05-03 19:51:57 -05:00
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
2026-05-03 19:51:57 -05:00
const { getDb, getSetting } = require('../db/database.cts');
const {
hashPassword,
invalidateOtherSessions,
rotateSessionId,
COOKIE_NAME,
cookieOpts,
} = require('../services/authService.cts');
const { getImportHistory } = require('../services/spreadsheetImportService.cts');
const { logAudit } = require('../services/auditService.cts');
const {
ApiError,
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts');
2026-05-03 19:51:57 -05:00
// All profile routes require authentication — enforced in server.js.
// req.user is always the signed-in user; user_id is never accepted from the body.
function dataImportEnabled() {
return String(process.env.DATA_IMPORT_ENABLED ?? 'true').toLowerCase() !== 'false';
}
function requireDataImportEnabled(req: Req, res: Res, next: Next) {
if (!dataImportEnabled()) {
throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false');
}
next();
}
function profileResponse(user: any) {
const displayName = user.display_name || null;
return {
id: user.id,
username: user.username,
display_name: displayName,
displayName,
name: displayName || user.username,
role: user.role,
active: !!user.active,
is_default_admin: !!user.is_default_admin,
created_at: user.created_at,
updated_at: user.updated_at,
last_password_change_at: user.last_password_change_at || null,
first_login: !!user.first_login,
};
}
2026-05-03 19:51:57 -05:00
// ── GET /api/profile ──────────────────────────────────────────────────────────
// Returns safe profile data for the signed-in user.
// Never returns password_hash, session tokens, or secrets.
router.get('/', (req: Req, res: Res) => {
const db = getDb();
const user = db
.prepare(
`
2026-05-04 23:34:24 -05:00
SELECT id, username, display_name, role, active, is_default_admin,
2026-05-03 19:51:57 -05:00
first_login, created_at, updated_at,
last_password_change_at,
notification_email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue
FROM users WHERE id = ?
`,
)
.get(req.user.id);
2026-05-03 19:51:57 -05:00
if (!user) throw NotFoundError('User not found');
2026-05-03 19:51:57 -05:00
res.json({
...profileResponse(user),
2026-05-03 19:51:57 -05:00
notifications: {
email: user.notification_email || null,
enabled: !!user.notifications_enabled,
notify_3d: !!user.notify_3d,
notify_1d: !!user.notify_1d,
notify_due: !!user.notify_due,
2026-05-03 19:51:57 -05:00
notify_overdue: !!user.notify_overdue,
},
exports: {
user_db: '/api/export/user-db',
2026-05-03 19:51:57 -05:00
user_excel: '/api/export/user-excel',
},
import_history_url: '/api/profile/import-history',
});
});
// ── PATCH /api/profile ────────────────────────────────────────────────────────
2026-05-14 01:17:05 -05:00
// Updates safe profile fields: username and display_name.
2026-05-03 19:51:57 -05:00
// Ignores any unknown or restricted fields.
router.patch('/', (req: Req, res: Res) => {
const { username } = req.body;
const displayNameInput =
req.body.display_name !== undefined
? req.body.display_name
: req.body.displayName !== undefined
? req.body.displayName
: req.body.name;
2026-05-14 01:17:05 -05:00
const db = getDb();
if (username !== undefined) {
if (typeof username !== 'string') {
throw ValidationError('username must be a string', 'username');
2026-05-14 01:17:05 -05:00
}
const trimmedUsername = username.trim();
if (trimmedUsername.length < 3) {
throw ValidationError('username must be at least 3 characters', 'username');
2026-05-14 01:17:05 -05:00
}
if (trimmedUsername.length > 50) {
throw ValidationError('username must be 50 characters or fewer', 'username');
2026-05-14 01:17:05 -05:00
}
const taken = db
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmedUsername, req.user.id);
2026-05-14 01:17:05 -05:00
if (taken) {
throw ConflictError('Username already taken', 'username');
2026-05-14 01:17:05 -05:00
}
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
trimmedUsername,
req.user.id,
);
logAudit({
user_id: req.user.id,
action: 'profile.username.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
2026-05-14 01:17:05 -05:00
}
2026-05-03 19:51:57 -05:00
if (displayNameInput !== undefined) {
if (typeof displayNameInput !== 'string') {
throw ValidationError('display_name must be a string', 'display_name');
2026-05-03 19:51:57 -05:00
}
const trimmed = displayNameInput.trim();
2026-05-03 19:51:57 -05:00
if (trimmed.length > 100) {
throw ValidationError('display_name must be 100 characters or fewer', 'display_name');
2026-05-03 19:51:57 -05:00
}
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(
trimmed || null,
req.user.id,
);
logAudit({
user_id: req.user.id,
action: 'profile.update',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
2026-05-03 19:51:57 -05:00
}
const updated = getDb()
.prepare(
`
2026-05-04 23:34:24 -05:00
SELECT id, username, display_name, role, active, is_default_admin,
first_login, created_at, updated_at,
last_password_change_at
FROM users WHERE id = ?
`,
)
.get(req.user.id);
2026-05-04 23:34:24 -05:00
res.json({ success: true, profile: profileResponse(updated) });
2026-05-03 19:51:57 -05:00
});
// ── GET /api/profile/settings ─────────────────────────────────────────────────
// Returns user-owned notification preferences from the users table.
// Does not return admin/global/SMTP settings.
router.get('/settings', (req: Req, res: Res) => {
const db = getDb();
const user = db
.prepare(
`
2026-05-03 19:51:57 -05:00
SELECT notification_email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
notify_push_enabled, push_channel, push_url, push_token, push_chat_id,
geolocation_enabled
2026-05-03 19:51:57 -05:00
FROM users WHERE id = ?
`,
)
.get(req.user.id);
2026-05-03 19:51:57 -05:00
if (!user) throw NotFoundError('User not found');
2026-05-03 19:51:57 -05:00
// Decrypt push secrets — return masked indicator for token (never the raw value)
const pushUrlDecrypted = user.push_url
? (() => {
try {
return decryptSecret(user.push_url);
} catch {
return user.push_url;
}
})()
: null;
2026-05-03 19:51:57 -05:00
res.json({
notification_email: user.notification_email || null,
2026-05-03 19:51:57 -05:00
notifications_enabled: !!user.notifications_enabled,
notify_3d: !!user.notify_3d,
notify_1d: !!user.notify_1d,
notify_due: !!user.notify_due,
notify_overdue: !!user.notify_overdue,
notify_amount_change: user.notify_amount_change !== 0,
notify_push_enabled: !!user.notify_push_enabled,
push_channel: user.push_channel || null,
push_url: pushUrlDecrypted || null,
push_token_set: !!user.push_token,
push_chat_id: user.push_chat_id || null,
geolocation_enabled: !!user.geolocation_enabled,
2026-05-03 19:51:57 -05:00
});
});
// ── PATCH /api/profile/settings ───────────────────────────────────────────────
// Updates user-owned notification preferences only.
// Cannot modify global/admin/SMTP settings through this endpoint.
const VALID_PUSH_CHANNELS = new Set(['ntfy', 'gotify', 'discord', 'telegram']);
router.patch('/settings', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
const {
notification_email,
email,
notifications_enabled,
notify_3d,
notify_1d,
notify_due,
notify_overdue,
notify_amount_change,
notify_push_enabled,
push_channel,
push_url,
push_token,
push_chat_id,
geolocation_enabled,
2026-05-03 19:51:57 -05:00
} = req.body;
2026-05-04 23:34:24 -05:00
const nextEmail = notification_email !== undefined ? notification_email : email;
if (nextEmail !== undefined && nextEmail !== null) {
if (typeof nextEmail !== 'string') {
throw ValidationError('notification_email must be a string', 'notification_email');
2026-05-03 19:51:57 -05:00
}
2026-05-04 23:34:24 -05:00
if (nextEmail.trim().length > 255) {
throw ValidationError('notification_email is too long', 'notification_email');
2026-05-03 19:51:57 -05:00
}
}
if (
push_channel !== undefined &&
push_channel !== null &&
!VALID_PUSH_CHANNELS.has(push_channel)
) {
return res
.status(400)
.json({ error: `push_channel must be one of: ${[...VALID_PUSH_CHANNELS].join(', ')}` });
}
const current = db
.prepare(
`
2026-05-03 19:51:57 -05:00
SELECT notification_email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
notify_push_enabled, push_channel, push_url, push_token, push_chat_id,
geolocation_enabled
2026-05-03 19:51:57 -05:00
FROM users WHERE id = ?
`,
)
.get(req.user.id);
2026-05-03 19:51:57 -05:00
const emailVal =
nextEmail !== undefined
? nextEmail
? nextEmail.trim() || null
: null
: current.notification_email;
2026-05-03 19:51:57 -05:00
const boolVal = (incoming: any, fallback: any) =>
incoming !== undefined ? (incoming ? 1 : 0) : fallback;
2026-05-03 19:51:57 -05:00
// Encrypt push_url and push_token before storing
const encryptOrNull = (v: any, fallback: any) => {
if (v === undefined) return fallback;
if (!v) return null;
try {
return encryptSecret(String(v).trim());
} catch {
return String(v).trim();
}
};
db.prepare(
`
2026-05-03 19:51:57 -05:00
UPDATE users SET
notification_email = ?,
notifications_enabled = ?,
notify_3d = ?,
notify_1d = ?,
notify_due = ?,
notify_overdue = ?,
notify_amount_change = ?,
notify_push_enabled = ?,
push_channel = ?,
push_url = ?,
push_token = ?,
push_chat_id = ?,
geolocation_enabled = ?,
2026-05-03 19:51:57 -05:00
updated_at = datetime('now')
WHERE id = ?
`,
).run(
2026-05-03 19:51:57 -05:00
emailVal,
boolVal(notifications_enabled, current.notifications_enabled),
boolVal(notify_3d, current.notify_3d),
boolVal(notify_1d, current.notify_1d),
boolVal(notify_due, current.notify_due),
boolVal(notify_overdue, current.notify_overdue),
boolVal(notify_amount_change, current.notify_amount_change),
boolVal(notify_push_enabled, current.notify_push_enabled),
push_channel !== undefined ? push_channel || null : current.push_channel,
encryptOrNull(push_url, current.push_url),
encryptOrNull(push_token, current.push_token),
push_chat_id !== undefined ? push_chat_id?.trim() || null : current.push_chat_id,
boolVal(geolocation_enabled, current.geolocation_enabled),
2026-05-03 19:51:57 -05:00
req.user.id,
);
logAudit({
user_id: req.user.id,
action: 'profile.settings.update',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
2026-05-03 19:51:57 -05:00
res.json({ success: true });
});
// ── POST /api/profile/change-password ─────────────────────────────────────────
// Changes the signed-in user's password.
// Always requires: current_password, new_password, confirm_new_password.
// Never bypasses current_password verification regardless of must_change_password.
// Never accepts user_id from the request body.
router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const { current_password, new_password, confirm_new_password } = req.body;
if (!current_password) {
throw ValidationError('current_password is required', 'current_password');
2026-05-03 19:51:57 -05:00
}
if (!new_password) {
throw ValidationError('new_password is required', 'new_password');
2026-05-03 19:51:57 -05:00
}
if (!confirm_new_password) {
throw ValidationError('confirm_new_password is required', 'confirm_new_password');
2026-05-03 19:51:57 -05:00
}
if (new_password !== confirm_new_password) {
throw ValidationError('new passwords do not match', 'confirm_new_password');
2026-05-03 19:51:57 -05:00
}
if (new_password.length < 8) {
throw ValidationError('new password must be at least 8 characters', 'new_password');
2026-05-03 19:51:57 -05:00
}
const db = getDb();
2026-05-03 19:51:57 -05:00
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
if (!user) throw NotFoundError('User not found');
2026-05-03 19:51:57 -05:00
const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) {
throw new ApiError('AUTH_ERROR', 'current password is incorrect', 401, {
field: 'current_password',
});
}
2026-05-03 19:51:57 -05:00
const hash = await hashPassword(new_password);
2026-05-31 15:06:10 -05:00
db.prepare(
`
2026-05-31 15:06:10 -05:00
UPDATE users
SET password_hash = ?, must_change_password = 0,
last_password_change_at = datetime('now'),
updated_at = datetime('now')
WHERE id = ?
`,
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
2026-05-31 15:06:10 -05:00
}
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
2026-05-03 19:51:57 -05:00
});
// ── GET /api/profile/exports ──────────────────────────────────────────────────
// Returns metadata about available user export capabilities.
// Does not trigger any download; links point to the actual export endpoints.
router.get('/exports', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
res.json({
exports: [
{
id: 'user_db',
label: 'SQLite database export',
description: 'Your bills, categories, payments, and notes as a portable SQLite file',
url: '/api/export/user-db',
method: 'GET',
2026-05-03 19:51:57 -05:00
},
{
id: 'user_excel',
label: 'Excel databook export',
description: 'Your data as an Excel workbook with multiple sheets',
url: '/api/export/user-excel',
method: 'GET',
2026-05-03 19:51:57 -05:00
},
],
});
});
// ── GET /api/profile/import-history ──────────────────────────────────────────
// Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history.
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
const history = getImportHistory(req.user.id);
res.json({ history });
2026-05-03 19:51:57 -05:00
});
module.exports = router;