mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
6072b969d6
* fix: collab chat input hidden by mobile bottom nav bar Closes #939 * chore: prepare database for nest + typeorm * fix(ssrf): relax internal network resolution (#947) * docs(ssrf): update Internal-Network-Access wiki to reflect relaxed guard Loopback, link-local, and .local/.internal hostnames are now all overridable with ALLOW_INTERNAL_NETWORK=true (commit9a08368). Merge the two-tier "always blocked / conditionally blocked" structure into a single table, add a warning about cloud metadata exposure. * fix(ssrf): let .local/.internal hostnames pass to IP-level checks The pre-DNS hostname block was redundant: any .local/.internal host that resolves to a private IP is already gated by isPrivateNetwork + ALLOW_INTERNAL_NETWORK, and any that resolves to loopback/link-local is caught by isAlwaysBlocked unconditionally. Dropping the hostname pre-check means Docker/LAN deployments can reach services on .local hostnames (e.g. immich.local) with ALLOW_INTERNAL_NETWORK=true, while loopback and link-local IPs (including 169.254.169.254) remain hard-blocked with no override. Reverts the isAlwaysBlocked guard loosening from9a08368. * fix(auth): trim username and email on all write paths Self-registration stored values verbatim, so trailing whitespace could produce rows that lookup code (which trims input) silently misses. Trim username and email before validation and INSERT in registerUser, adminService.updateUser, and oidcService.findOrCreateUser. updateSettings and adminService.createUser already trimmed correctly. Adds a one-shot backfill migration (trimUserWhitespace) that trims existing dirty rows; collisions are resolved by appending __migrated_<id> to the value with a loud console.warn so operators can review affected accounts. 18 new tests covering registration trim, duplicate detection, admin update trim, trip-member lookup regression, and all migration branches. * feat(notices): add v3014-whitespace-collision admin notice Adds a dismissible banner for admins on v3.0.14+ that fires only when the whitespace-trimming migration detected a username/email collision (stored in app_settings as whitespace_migration_collision=true). Notice conditions: existingUserBeforeVersion(3.0.14) + role=admin + custom predicate reading the app_settings flag. Predicate registered in registry.ts; migration step writes the flag when hadCollision=true. All 15 translation files updated with title/body keys. 7 integration tests added (SN-COLLISION-1 through -7) covering all condition branches: shown when all conditions met, hidden when flag absent/false, hidden for non-admin, hidden for new user, hidden below min app version, hidden after dismissal.
778 lines
35 KiB
TypeScript
778 lines
35 KiB
TypeScript
import bcrypt from 'bcryptjs';
|
|
import crypto from 'crypto';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { db } from '../db/database';
|
|
import { User, Addon } from '../types';
|
|
import { updateJwtSecret } from '../config';
|
|
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
|
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
|
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
|
import { deleteUserCompletely } from './userCleanupService';
|
|
import { validatePassword } from './passwordPolicy';
|
|
import { getPhotoProviderConfig } from './memories/helpersService';
|
|
import { send as sendNotification } from './notificationService';
|
|
import { resolveAuthToggles } from './authService';
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
export function utcSuffix(ts: string | null | undefined): string | null {
|
|
if (!ts) return null;
|
|
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
|
}
|
|
|
|
export function compareVersions(a: string, b: string): number {
|
|
const parse = (v: string) => {
|
|
const [base, pre] = v.split('-pre.');
|
|
const parts = base.split('.').map(Number);
|
|
const n = pre !== undefined ? parseInt(pre, 10) : null;
|
|
const preN = n !== null && Number.isFinite(n) ? n : null;
|
|
return { parts, preN };
|
|
};
|
|
const pa = parse(a), pb = parse(b);
|
|
for (let i = 0; i < Math.max(pa.parts.length, pb.parts.length); i++) {
|
|
const na = pa.parts[i] || 0, nb = pb.parts[i] || 0;
|
|
if (na > nb) return 1;
|
|
if (na < nb) return -1;
|
|
}
|
|
// Equal base: stable > prerelease; higher preN wins among prereleases
|
|
if (pa.preN === null && pb.preN !== null) return 1;
|
|
if (pa.preN !== null && pb.preN === null) return -1;
|
|
if (pa.preN !== null && pb.preN !== null) {
|
|
if (pa.preN > pb.preN) return 1;
|
|
if (pa.preN < pb.preN) return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export const isDocker = (() => {
|
|
try {
|
|
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
|
|
} catch { return false; }
|
|
})();
|
|
|
|
// ── User CRUD ──────────────────────────────────────────────────────────────
|
|
|
|
export function listUsers() {
|
|
const users = db.prepare(
|
|
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
|
).all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & { avatar?: string | null })[];
|
|
let onlineUserIds = new Set<number>();
|
|
try {
|
|
const { getOnlineUserIds } = require('../websocket');
|
|
onlineUserIds = getOnlineUserIds();
|
|
} catch { /* */ }
|
|
return users.map(u => ({
|
|
...u,
|
|
avatar_url: u.avatar ? `/uploads/avatars/${u.avatar}` : null,
|
|
created_at: utcSuffix(u.created_at),
|
|
updated_at: utcSuffix(u.updated_at as string),
|
|
last_login: utcSuffix(u.last_login),
|
|
online: onlineUserIds.has(u.id),
|
|
}));
|
|
}
|
|
|
|
export function createUser(data: { username: string; email: string; password: string; role?: string }) {
|
|
const username = data.username?.trim();
|
|
const email = data.email?.trim();
|
|
const password = data.password?.trim();
|
|
|
|
if (!username || !email || !password) {
|
|
return { error: 'Username, email and password are required', status: 400 };
|
|
}
|
|
|
|
const pwCheck = validatePassword(password);
|
|
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
|
|
|
if (data.role && !['user', 'admin'].includes(data.role)) {
|
|
return { error: 'Invalid role', status: 400 };
|
|
}
|
|
|
|
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
|
if (existingUsername) return { error: 'Username already taken', status: 409 };
|
|
|
|
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
|
if (existingEmail) return { error: 'Email already taken', status: 409 };
|
|
|
|
const passwordHash = bcrypt.hashSync(password, 12);
|
|
|
|
const result = db.prepare(
|
|
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
|
).run(username, email, passwordHash, data.role || 'user');
|
|
|
|
const user = db.prepare(
|
|
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
|
).get(result.lastInsertRowid);
|
|
|
|
return {
|
|
user,
|
|
insertedId: Number(result.lastInsertRowid),
|
|
auditDetails: { username, email, role: data.role || 'user' },
|
|
};
|
|
}
|
|
|
|
export function updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
|
|
const username = typeof data.username === 'string' ? data.username.trim() : data.username;
|
|
const email = typeof data.email === 'string' ? data.email.trim() : data.email;
|
|
const { role, password } = data;
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
|
|
|
|
if (!user) return { error: 'User not found', status: 404 };
|
|
|
|
if (role && !['user', 'admin'].includes(role)) {
|
|
return { error: 'Invalid role', status: 400 };
|
|
}
|
|
|
|
if (username && username !== user.username) {
|
|
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, id);
|
|
if (conflict) return { error: 'Username already taken', status: 409 };
|
|
}
|
|
if (email && email !== user.email) {
|
|
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id);
|
|
if (conflict) return { error: 'Email already taken', status: 409 };
|
|
}
|
|
|
|
if (password) {
|
|
const pwCheck = validatePassword(password);
|
|
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
|
}
|
|
const passwordHash = password ? bcrypt.hashSync(password, 12) : null;
|
|
|
|
db.prepare(`
|
|
UPDATE users SET
|
|
username = COALESCE(?, username),
|
|
email = COALESCE(?, email),
|
|
role = COALESCE(?, role),
|
|
password_hash = COALESCE(?, password_hash),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`).run(username || null, email || null, role || null, passwordHash, id);
|
|
|
|
const updated = db.prepare(
|
|
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
|
).get(id);
|
|
|
|
const changed: string[] = [];
|
|
if (username) changed.push('username');
|
|
if (email) changed.push('email');
|
|
if (role) changed.push('role');
|
|
if (password) changed.push('password');
|
|
|
|
return {
|
|
user: updated,
|
|
previousEmail: user.email,
|
|
changed,
|
|
};
|
|
}
|
|
|
|
export function deleteUser(id: string, currentUserId: number) {
|
|
if (parseInt(id) === currentUserId) {
|
|
return { error: 'Cannot delete own account', status: 400 };
|
|
}
|
|
|
|
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
|
|
if (!userToDel) return { error: 'User not found', status: 404 };
|
|
|
|
deleteUserCompletely(userToDel.id);
|
|
return { email: userToDel.email };
|
|
}
|
|
|
|
// ── Stats ──────────────────────────────────────────────────────────────────
|
|
|
|
export function getStats() {
|
|
const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
|
const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count;
|
|
const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count;
|
|
const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count;
|
|
return { totalUsers, totalTrips, totalPlaces, totalFiles };
|
|
}
|
|
|
|
// ── Permissions ────────────────────────────────────────────────────────────
|
|
|
|
export function getPermissions() {
|
|
const current = getAllPermissions();
|
|
const actions = PERMISSION_ACTIONS.map(a => ({
|
|
key: a.key,
|
|
level: current[a.key],
|
|
defaultLevel: a.defaultLevel,
|
|
allowedLevels: a.allowedLevels,
|
|
}));
|
|
return { permissions: actions };
|
|
}
|
|
|
|
export function savePermissions(permissions: Record<string, string>) {
|
|
const { skipped } = savePerms(permissions);
|
|
return { permissions: getAllPermissions(), skipped };
|
|
}
|
|
|
|
// ── Audit Log ──────────────────────────────────────────────────────────────
|
|
|
|
export function getAuditLog(query: { limit?: string; offset?: string }) {
|
|
const limitRaw = parseInt(String(query.limit || '100'), 10);
|
|
const offsetRaw = parseInt(String(query.offset || '0'), 10);
|
|
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
|
|
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
|
|
|
|
type Row = {
|
|
id: number;
|
|
created_at: string;
|
|
user_id: number | null;
|
|
username: string | null;
|
|
user_email: string | null;
|
|
action: string;
|
|
resource: string | null;
|
|
details: string | null;
|
|
ip: string | null;
|
|
};
|
|
|
|
const rows = db.prepare(`
|
|
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
|
FROM audit_log a
|
|
LEFT JOIN users u ON u.id = a.user_id
|
|
ORDER BY a.id DESC
|
|
LIMIT ? OFFSET ?
|
|
`).all(limit, offset) as Row[];
|
|
|
|
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
|
|
|
|
const entries = rows.map((r) => {
|
|
let details: Record<string, unknown> | null = null;
|
|
if (r.details) {
|
|
try {
|
|
details = JSON.parse(r.details) as Record<string, unknown>;
|
|
} catch {
|
|
details = { _parse_error: true };
|
|
}
|
|
}
|
|
const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
|
|
return { ...r, created_at, details };
|
|
});
|
|
|
|
return { entries, total, limit, offset };
|
|
}
|
|
|
|
// ── OIDC Settings ──────────────────────────────────────────────────────────
|
|
|
|
export function getOidcSettings() {
|
|
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
|
|
const secret = decrypt_api_key(get('oidc_client_secret'));
|
|
return {
|
|
issuer: get('oidc_issuer'),
|
|
client_id: get('oidc_client_id'),
|
|
client_secret_set: !!secret,
|
|
display_name: get('oidc_display_name'),
|
|
oidc_only: get('oidc_only') === 'true',
|
|
discovery_url: get('oidc_discovery_url'),
|
|
};
|
|
}
|
|
|
|
export function updateOidcSettings(data: {
|
|
issuer?: string;
|
|
client_id?: string;
|
|
client_secret?: string;
|
|
display_name?: string;
|
|
discovery_url?: string;
|
|
}): { error?: string; status?: number; success?: boolean } {
|
|
// Lockout prevention: can't remove OIDC config when password login is disabled
|
|
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
|
|
return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400 };
|
|
}
|
|
|
|
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
|
|
set('oidc_issuer', data.issuer ?? '');
|
|
set('oidc_client_id', data.client_id ?? '');
|
|
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
|
|
set('oidc_display_name', data.display_name ?? '');
|
|
set('oidc_discovery_url', data.discovery_url ?? '');
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
|
|
|
export function saveDemoBaseline(): { error?: string; status?: number; message?: string } {
|
|
if (process.env.DEMO_MODE?.toLowerCase() !== 'true') {
|
|
return { error: 'Not found', status: 404 };
|
|
}
|
|
try {
|
|
const { saveBaseline } = require('../demo/demo-reset');
|
|
saveBaseline();
|
|
return { message: 'Demo baseline saved. Hourly resets will restore to this state.' };
|
|
} catch (err: unknown) {
|
|
console.error(err);
|
|
return { error: 'Failed to save baseline', status: 500 };
|
|
}
|
|
}
|
|
|
|
// ── GitHub Integration ─────────────────────────────────────────────────────
|
|
|
|
export async function getGithubReleases(perPage: string = '10', page: string = '1') {
|
|
try {
|
|
const resp = await fetch(
|
|
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`,
|
|
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
|
);
|
|
if (!resp.ok) return [];
|
|
const data = await resp.json();
|
|
return Array.isArray(data) ? data : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
interface VersionInfo {
|
|
current: string;
|
|
latest: string;
|
|
update_available: boolean;
|
|
release_url?: string;
|
|
is_docker: boolean;
|
|
is_prerelease: boolean;
|
|
}
|
|
|
|
const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
let _versionCache: { data: VersionInfo; expiresAt: number } | null = null;
|
|
|
|
/** Test-only: clear the in-memory version cache. */
|
|
export function __clearVersionCacheForTests(): void {
|
|
_versionCache = null;
|
|
}
|
|
|
|
export async function checkVersion(): Promise<VersionInfo> {
|
|
if (_versionCache && Date.now() < _versionCache.expiresAt) {
|
|
return _versionCache.data;
|
|
}
|
|
|
|
const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
|
|
const isPrerelease = currentVersion.includes('-pre.');
|
|
const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
|
|
let result: VersionInfo;
|
|
try {
|
|
if (isPrerelease) {
|
|
// Fetch release list and find the newest prerelease
|
|
const resp = await fetch(
|
|
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100',
|
|
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
|
);
|
|
if (!resp.ok) {
|
|
return fallback;
|
|
}
|
|
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
|
|
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
|
|
if (!prereleases.length) {
|
|
return fallback;
|
|
}
|
|
// Pre-compute stripped versions, then sort descending
|
|
const tagged = prereleases.map(r => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
|
|
tagged.sort((a, b) => compareVersions(b.v, a.v));
|
|
const latest = tagged[0].v;
|
|
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
|
result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true };
|
|
} else {
|
|
const resp = await fetch(
|
|
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
|
|
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
|
);
|
|
if (!resp.ok) {
|
|
return fallback;
|
|
}
|
|
const data = await resp.json() as { tag_name?: string; html_url?: string };
|
|
const latest = (data.tag_name || '').replace(/^v/, '');
|
|
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
|
result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
|
|
}
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
|
|
_versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL };
|
|
return result;
|
|
}
|
|
|
|
export async function checkAndNotifyVersion(): Promise<void> {
|
|
try {
|
|
const result = await checkVersion();
|
|
if (!result.update_available) return;
|
|
|
|
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
|
if (lastNotified === result.latest) return;
|
|
|
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
|
|
|
|
await sendNotification({
|
|
event: 'version_available',
|
|
actorId: null,
|
|
scope: 'admin',
|
|
targetId: 0,
|
|
params: { version: result.latest },
|
|
});
|
|
} catch {
|
|
// Silently ignore — version check is non-critical
|
|
}
|
|
}
|
|
|
|
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
|
|
|
export function listInvites() {
|
|
return db.prepare(`
|
|
SELECT i.*, u.username as created_by_name
|
|
FROM invite_tokens i
|
|
JOIN users u ON i.created_by = u.id
|
|
ORDER BY i.created_at DESC
|
|
`).all();
|
|
}
|
|
|
|
export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number }) {
|
|
const rawUses = parseInt(String(data.max_uses));
|
|
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
|
|
const token = crypto.randomBytes(16).toString('hex');
|
|
const expiresAt = data.expires_in_days
|
|
? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString()
|
|
: null;
|
|
|
|
const ins = db.prepare(
|
|
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
|
|
).run(token, uses, expiresAt, createdBy);
|
|
|
|
const inviteId = Number(ins.lastInsertRowid);
|
|
const invite = db.prepare(`
|
|
SELECT i.*, u.username as created_by_name
|
|
FROM invite_tokens i
|
|
JOIN users u ON i.created_by = u.id
|
|
WHERE i.id = ?
|
|
`).get(inviteId);
|
|
|
|
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null };
|
|
}
|
|
|
|
export function deleteInvite(id: string) {
|
|
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(id);
|
|
if (!invite) return { error: 'Invite not found', status: 404 };
|
|
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(id);
|
|
return {};
|
|
}
|
|
|
|
// ── Bag Tracking ───────────────────────────────────────────────────────────
|
|
|
|
export function getBagTracking() {
|
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
|
|
return { enabled: row?.value === 'true' };
|
|
}
|
|
|
|
export function updateBagTracking(enabled: boolean) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
|
|
return { enabled: !!enabled };
|
|
}
|
|
|
|
// ── Places Photos ─────────────────────────────────────────────────────────
|
|
|
|
export function getPlacesPhotos() {
|
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
|
return { enabled: row?.value !== 'false' };
|
|
}
|
|
|
|
export function updatePlacesPhotos(enabled: boolean) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
|
|
return { enabled: !!enabled };
|
|
}
|
|
|
|
// ── Places Autocomplete ────────────────────────────────────────────────────
|
|
|
|
export function getPlacesAutocomplete() {
|
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
|
|
return { enabled: row?.value !== 'false' };
|
|
}
|
|
|
|
export function updatePlacesAutocomplete(enabled: boolean) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
|
|
return { enabled: !!enabled };
|
|
}
|
|
|
|
// ── Places Details ─────────────────────────────────────────────────────────
|
|
|
|
export function getPlacesDetails() {
|
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
|
|
return { enabled: row?.value !== 'false' };
|
|
}
|
|
|
|
export function updatePlacesDetails(enabled: boolean) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
|
|
return { enabled: !!enabled };
|
|
}
|
|
|
|
// ── Collab Features ───────────────────────────────────────────────────────
|
|
|
|
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
|
|
|
export function getCollabFeatures() {
|
|
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
|
|
const map: Record<string, string> = {};
|
|
for (const r of rows) map[r.key] = r.value;
|
|
return {
|
|
chat: map['collab_chat_enabled'] !== 'false',
|
|
notes: map['collab_notes_enabled'] !== 'false',
|
|
polls: map['collab_polls_enabled'] !== 'false',
|
|
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
|
|
};
|
|
}
|
|
|
|
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
|
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
|
|
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
|
for (const [feat, key] of Object.entries(mapping)) {
|
|
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
|
}
|
|
return getCollabFeatures();
|
|
}
|
|
|
|
// ── Packing Templates ──────────────────────────────────────────────────────
|
|
|
|
export function listPackingTemplates() {
|
|
return db.prepare(`
|
|
SELECT pt.*, u.username as created_by_name,
|
|
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
|
|
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
|
|
FROM packing_templates pt
|
|
JOIN users u ON pt.created_by = u.id
|
|
ORDER BY pt.created_at DESC
|
|
`).all();
|
|
}
|
|
|
|
export function getPackingTemplate(id: string) {
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(id) as any[];
|
|
const items = db.prepare(`
|
|
SELECT ti.* FROM packing_template_items ti
|
|
JOIN packing_template_categories tc ON ti.category_id = tc.id
|
|
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
|
|
`).all(id);
|
|
return { template, categories, items };
|
|
}
|
|
|
|
export function createPackingTemplate(name: string, createdBy: number) {
|
|
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
|
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), createdBy);
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
|
|
return { template };
|
|
}
|
|
|
|
export function updatePackingTemplate(id: string, data: { name?: string }) {
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
if (data.name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(data.name.trim(), id);
|
|
return { template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) };
|
|
}
|
|
|
|
export function deletePackingTemplate(id: string) {
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) as { name?: string } | undefined;
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(id);
|
|
return { name: template.name };
|
|
}
|
|
|
|
// Template categories
|
|
|
|
export function createTemplateCategory(templateId: string, name: string) {
|
|
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(templateId);
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(templateId) as { max: number | null };
|
|
const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
|
|
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) };
|
|
}
|
|
|
|
export function updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
|
|
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
|
|
if (!cat) return { error: 'Category not found', status: 404 };
|
|
if (data.name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
|
|
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(catId) };
|
|
}
|
|
|
|
export function deleteTemplateCategory(templateId: string, catId: string) {
|
|
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
|
|
if (!cat) return { error: 'Category not found', status: 404 };
|
|
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(catId);
|
|
return {};
|
|
}
|
|
|
|
// Template items
|
|
|
|
export function createTemplateItem(templateId: string, catId: string, name: string) {
|
|
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
|
|
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
|
|
if (!cat) return { error: 'Category not found', status: 404 };
|
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(catId) as { max: number | null };
|
|
const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
|
|
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) };
|
|
}
|
|
|
|
export function updateTemplateItem(itemId: string, data: { name?: string }) {
|
|
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
|
|
if (!item) return { error: 'Item not found', status: 404 };
|
|
if (data.name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
|
|
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId) };
|
|
}
|
|
|
|
export function deleteTemplateItem(itemId: string) {
|
|
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
|
|
if (!item) return { error: 'Item not found', status: 404 };
|
|
db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(itemId);
|
|
return {};
|
|
}
|
|
|
|
// ── Addons ─────────────────────────────────────────────────────────────────
|
|
|
|
export function isAddonEnabled(addonId: string): boolean {
|
|
const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as { enabled: number } | undefined;
|
|
return !!addon?.enabled;
|
|
}
|
|
|
|
export function listAddons() {
|
|
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
|
const providers = db.prepare(`
|
|
SELECT id, name, description, icon, enabled, sort_order
|
|
FROM photo_providers
|
|
ORDER BY sort_order, id
|
|
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>;
|
|
const fields = db.prepare(`
|
|
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
|
FROM photo_provider_fields
|
|
ORDER BY sort_order, id
|
|
`).all() as Array<{
|
|
provider_id: string;
|
|
field_key: string;
|
|
label: string;
|
|
input_type: string;
|
|
placeholder?: string | null;
|
|
required: number;
|
|
secret: number;
|
|
settings_key?: string | null;
|
|
payload_key?: string | null;
|
|
sort_order: number;
|
|
}>;
|
|
const fieldsByProvider = new Map<string, typeof fields>();
|
|
for (const field of fields) {
|
|
const arr = fieldsByProvider.get(field.provider_id) || [];
|
|
arr.push(field);
|
|
fieldsByProvider.set(field.provider_id, arr);
|
|
}
|
|
|
|
return [
|
|
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
|
|
...providers.map(p => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
description: p.description,
|
|
type: 'photo_provider',
|
|
icon: p.icon,
|
|
enabled: !!p.enabled,
|
|
config: getPhotoProviderConfig(p.id),
|
|
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
|
key: f.field_key,
|
|
label: f.label,
|
|
input_type: f.input_type,
|
|
placeholder: f.placeholder || '',
|
|
required: !!f.required,
|
|
secret: !!f.secret,
|
|
settings_key: f.settings_key || null,
|
|
payload_key: f.payload_key || null,
|
|
sort_order: f.sort_order,
|
|
})),
|
|
sort_order: p.sort_order,
|
|
})),
|
|
];
|
|
}
|
|
|
|
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
|
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
|
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
|
|
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
|
|
|
if (addon) {
|
|
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
|
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
|
} else {
|
|
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
|
}
|
|
|
|
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
|
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
|
|
const updated = updatedAddon
|
|
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
|
|
: updatedProvider
|
|
? {
|
|
id: updatedProvider.id,
|
|
name: updatedProvider.name,
|
|
description: updatedProvider.description,
|
|
type: 'photo_provider',
|
|
icon: updatedProvider.icon,
|
|
enabled: !!updatedProvider.enabled,
|
|
config: getPhotoProviderConfig(updatedProvider.id),
|
|
sort_order: updatedProvider.sort_order,
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
addon: updated,
|
|
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
|
|
};
|
|
}
|
|
|
|
// ── MCP Tokens ─────────────────────────────────────────────────────────────
|
|
|
|
export function listMcpTokens() {
|
|
return db.prepare(`
|
|
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
|
|
FROM mcp_tokens t
|
|
JOIN users u ON u.id = t.user_id
|
|
ORDER BY t.created_at DESC
|
|
`).all();
|
|
}
|
|
|
|
export function deleteMcpToken(id: string) {
|
|
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as { id: number; user_id: number } | undefined;
|
|
if (!token) return { error: 'Token not found', status: 404 };
|
|
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
|
|
revokeUserSessions(token.user_id);
|
|
return {};
|
|
}
|
|
|
|
// ── OAuth Sessions ─────────────────────────────────────────────────────────
|
|
|
|
export function listOAuthSessions() {
|
|
const rows = db.prepare(`
|
|
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
|
|
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
|
FROM oauth_tokens ot
|
|
JOIN oauth_clients oc ON ot.client_id = oc.client_id
|
|
JOIN users u ON u.id = ot.user_id
|
|
WHERE ot.revoked_at IS NULL
|
|
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
|
|
ORDER BY ot.created_at DESC
|
|
`).all() as (Record<string, unknown> & { scopes: string })[];
|
|
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
|
|
}
|
|
|
|
export function revokeOAuthSession(id: string) {
|
|
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined;
|
|
if (!row) return { error: 'Session not found', status: 404 };
|
|
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
|
revokeUserSessionsForClient(row.user_id, row.client_id);
|
|
return {};
|
|
}
|
|
|
|
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
|
|
|
export function rotateJwtSecret(): { error?: string; status?: number } {
|
|
const newSecret = crypto.randomBytes(32).toString('hex');
|
|
const dataDir = path.resolve(__dirname, '../../data');
|
|
const secretFile = path.join(dataDir, '.jwt_secret');
|
|
try {
|
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
fs.writeFileSync(secretFile, newSecret, { mode: 0o600 });
|
|
} catch (err: unknown) {
|
|
return { error: 'Failed to persist new JWT secret to disk', status: 500 };
|
|
}
|
|
updateJwtSecret(newSecret);
|
|
return {};
|
|
}
|