Merge remote-tracking branch 'origin/dev' into asteriskyg/main
# Conflicts: # server/src/routes/files.ts
This commit is contained in:
@@ -7,11 +7,18 @@ import fs from 'fs';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest, User, Addon } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate, adminOnly);
|
||||
|
||||
function utcSuffix(ts: string | null | undefined): string | null {
|
||||
if (!ts) return null;
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
router.get('/users', (req: Request, res: Response) => {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
@@ -21,7 +28,13 @@ router.get('/users', (req: Request, res: Response) => {
|
||||
const { getOnlineUserIds } = require('../websocket');
|
||||
onlineUserIds = getOnlineUserIds();
|
||||
} catch { /* */ }
|
||||
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
|
||||
const usersWithStatus = users.map(u => ({
|
||||
...u,
|
||||
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),
|
||||
}));
|
||||
res.json({ users: usersWithStatus });
|
||||
});
|
||||
|
||||
@@ -52,6 +65,14 @@ router.post('/users', (req: Request, res: Response) => {
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_create',
|
||||
resource: String(result.lastInsertRowid),
|
||||
ip: getClientIp(req),
|
||||
details: { username: username.trim(), email: email.trim(), role: role || 'user' },
|
||||
});
|
||||
res.status(201).json({ user });
|
||||
});
|
||||
|
||||
@@ -90,6 +111,19 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(req.params.id);
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const changed: string[] = [];
|
||||
if (username) changed.push('username');
|
||||
if (email) changed.push('email');
|
||||
if (role) changed.push('role');
|
||||
if (password) changed.push('password');
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_update',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { fields: changed },
|
||||
});
|
||||
res.json({ user: updated });
|
||||
});
|
||||
|
||||
@@ -103,6 +137,12 @@ router.delete('/users/:id', (req: Request, res: Response) => {
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -115,6 +155,48 @@ router.get('/stats', (_req: Request, res: Response) => {
|
||||
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
|
||||
});
|
||||
|
||||
router.get('/audit-log', (req: Request, res: Response) => {
|
||||
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
|
||||
const offsetRaw = parseInt(String(req.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;
|
||||
res.json({
|
||||
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 };
|
||||
}
|
||||
}
|
||||
return { ...r, details };
|
||||
}),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/oidc', (_req: Request, res: Response) => {
|
||||
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
|
||||
const secret = get('oidc_client_secret');
|
||||
@@ -135,16 +217,25 @@ router.put('/oidc', (req: Request, res: Response) => {
|
||||
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
|
||||
set('oidc_display_name', display_name);
|
||||
set('oidc_only', oidc_only ? 'true' : 'false');
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.oidc_update',
|
||||
ip: getClientIp(req),
|
||||
details: { oidc_only: !!oidc_only, issuer_set: !!issuer },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/save-demo-baseline', (_req: Request, res: Response) => {
|
||||
router.post('/save-demo-baseline', (req: Request, res: Response) => {
|
||||
if (process.env.DEMO_MODE !== 'true') {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
try {
|
||||
const { saveBaseline } = require('../demo/demo-reset');
|
||||
saveBaseline();
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
@@ -201,7 +292,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/update', async (_req: Request, res: Response) => {
|
||||
router.post('/update', async (req: Request, res: Response) => {
|
||||
const rootDir = path.resolve(__dirname, '../../..');
|
||||
const serverDir = path.resolve(__dirname, '../..');
|
||||
const clientDir = path.join(rootDir, 'client');
|
||||
@@ -224,6 +315,13 @@ router.post('/update', async (_req: Request, res: Response) => {
|
||||
const { version: newVersion } = require('../../package.json');
|
||||
steps.push({ step: 'version', version: newVersion });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.system_update',
|
||||
resource: newVersion,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true, steps, restarting: true });
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -260,24 +358,39 @@ router.post('/invites', (req: Request, res: Response) => {
|
||||
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
|
||||
: null;
|
||||
|
||||
db.prepare(
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
|
||||
).run(token, uses, expiresAt, authReq.user.id);
|
||||
|
||||
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 = last_insert_rowid()
|
||||
`).get();
|
||||
WHERE i.id = ?
|
||||
`).get(inviteId);
|
||||
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.invite_create',
|
||||
resource: String(inviteId),
|
||||
ip: getClientIp(req),
|
||||
details: { max_uses: uses, expires_in_days: expires_in_days ?? null },
|
||||
});
|
||||
res.status(201).json({ invite });
|
||||
});
|
||||
|
||||
router.delete('/invites/:id', (_req: Request, res: Response) => {
|
||||
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id);
|
||||
router.delete('/invites/:id', (req: Request, res: Response) => {
|
||||
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id);
|
||||
if (!invite) return res.status(404).json({ error: 'Invite not found' });
|
||||
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id);
|
||||
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.invite_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -291,6 +404,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => {
|
||||
router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||
const { enabled } = req.body;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.bag_tracking',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: !!enabled },
|
||||
});
|
||||
res.json({ enabled: !!enabled });
|
||||
});
|
||||
|
||||
@@ -337,10 +457,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => {
|
||||
res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) });
|
||||
});
|
||||
|
||||
router.delete('/packing-templates/:id', (_req: Request, res: Response) => {
|
||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id);
|
||||
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
|
||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
|
||||
if (!template) return res.status(404).json({ error: 'Template not found' });
|
||||
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id);
|
||||
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
|
||||
const authReq = req as AuthRequest;
|
||||
const t = template as { name?: string };
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.packing_template_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { name: t.name },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -408,7 +537,33 @@ router.put('/addons/:id', (req: Request, res: Response) => {
|
||||
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
|
||||
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.addon_update',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined },
|
||||
});
|
||||
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
|
||||
});
|
||||
|
||||
router.get('/mcp-tokens', (req: Request, res: Response) => {
|
||||
const tokens = 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();
|
||||
res.json({ tokens });
|
||||
});
|
||||
|
||||
router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
|
||||
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(req.params.id) as { id: number; user_id: number } | undefined;
|
||||
if (!token) return res.status(404).json({ error: 'Token not found' });
|
||||
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(req.params.id);
|
||||
revokeUserSessions(token.user_id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -277,10 +277,10 @@ router.get('/bucket-list', (req: Request, res: Response) => {
|
||||
|
||||
router.post('/bucket-list', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name, lat, lng, country_code, notes } = req.body;
|
||||
const { name, lat, lng, country_code, notes, target_date } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)').run(
|
||||
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null
|
||||
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
|
||||
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null
|
||||
);
|
||||
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ item });
|
||||
@@ -288,10 +288,25 @@ router.post('/bucket-list', (req: Request, res: Response) => {
|
||||
|
||||
router.put('/bucket-list/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name, notes } = req.body;
|
||||
const { name, notes, lat, lng, country_code, target_date } = req.body;
|
||||
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
|
||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||
db.prepare('UPDATE bucket_list SET name = COALESCE(?, name), notes = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? null, req.params.id);
|
||||
db.prepare(`UPDATE bucket_list SET
|
||||
name = COALESCE(?, name),
|
||||
notes = CASE WHEN ? THEN ? ELSE notes END,
|
||||
lat = CASE WHEN ? THEN ? ELSE lat END,
|
||||
lng = CASE WHEN ? THEN ? ELSE lng END,
|
||||
country_code = CASE WHEN ? THEN ? ELSE country_code END,
|
||||
target_date = CASE WHEN ? THEN ? ELSE target_date END
|
||||
WHERE id = ?`).run(
|
||||
name?.trim() || null,
|
||||
notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null,
|
||||
lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null,
|
||||
lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null,
|
||||
country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null,
|
||||
target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null,
|
||||
req.params.id
|
||||
);
|
||||
res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import fetch from 'node-fetch';
|
||||
import { authenticator } from 'otplib';
|
||||
@@ -12,12 +13,45 @@ import { db } from '../db/database';
|
||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { AuthRequest, User } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||
const MFA_BACKUP_CODE_COUNT = 10;
|
||||
|
||||
function normalizeBackupCode(input: string): string {
|
||||
return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
function hashBackupCode(input: string): string {
|
||||
return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
|
||||
}
|
||||
|
||||
function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
|
||||
const codes: string[] = [];
|
||||
while (codes.length < count) {
|
||||
const raw = crypto.randomBytes(4).toString('hex').toUpperCase();
|
||||
const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
|
||||
if (!codes.includes(code)) codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
function parseBackupCodeHashes(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getPendingMfaSecret(userId: number): string | null {
|
||||
const row = mfaSetupPending.get(userId);
|
||||
@@ -28,6 +62,11 @@ function getPendingMfaSecret(userId: number): string | null {
|
||||
return row.secret;
|
||||
}
|
||||
|
||||
function utcSuffix(ts: string | null | undefined): string | null {
|
||||
if (!ts) return null;
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
function stripUserForClient(user: User): Record<string, unknown> {
|
||||
const {
|
||||
password_hash: _p,
|
||||
@@ -35,10 +74,14 @@ function stripUserForClient(user: User): Record<string, unknown> {
|
||||
openweather_api_key: _o,
|
||||
unsplash_api_key: _u,
|
||||
mfa_secret: _mf,
|
||||
mfa_backup_codes: _mbc,
|
||||
...rest
|
||||
} = user;
|
||||
return {
|
||||
...rest,
|
||||
created_at: utcSuffix(rest.created_at),
|
||||
updated_at: utcSuffix(rest.updated_at),
|
||||
last_login: utcSuffix(rest.last_login),
|
||||
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||
};
|
||||
}
|
||||
@@ -108,6 +151,11 @@ function maskKey(key: string | null | undefined): string | null {
|
||||
return '----' + key.slice(-4);
|
||||
}
|
||||
|
||||
function mask_stored_api_key(key: string | null | undefined): string | null {
|
||||
const plain = decrypt_api_key(key);
|
||||
return maskKey(plain);
|
||||
}
|
||||
|
||||
function avatarUrl(user: { avatar?: string | null }): string | null {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
@@ -134,6 +182,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
);
|
||||
const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
||||
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
||||
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||
res.json({
|
||||
allow_registration: isDemo ? false : allowRegistration,
|
||||
has_users: userCount > 0,
|
||||
@@ -142,10 +191,12 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
oidc_configured: oidcConfigured,
|
||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||
oidc_only_mode: oidcOnlyMode,
|
||||
require_mfa: requireMfaRow?.value === 'true',
|
||||
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,9 +395,9 @@ router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
db.prepare(
|
||||
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(maps_api_key || null, authReq.user.id);
|
||||
).run(maybe_encrypt_api_key(maps_api_key), authReq.user.id);
|
||||
|
||||
res.json({ success: true, maps_api_key: maps_api_key || null });
|
||||
res.json({ success: true, maps_api_key: mask_stored_api_key(maps_api_key) });
|
||||
});
|
||||
|
||||
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
|
||||
@@ -357,8 +408,8 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
|
||||
db.prepare(
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(
|
||||
maps_api_key !== undefined ? (maps_api_key || null) : current.maps_api_key,
|
||||
openweather_api_key !== undefined ? (openweather_api_key || null) : current.openweather_api_key,
|
||||
maps_api_key !== undefined ? maybe_encrypt_api_key(maps_api_key) : current.maps_api_key,
|
||||
openweather_api_key !== undefined ? maybe_encrypt_api_key(openweather_api_key) : current.openweather_api_key,
|
||||
authReq.user.id
|
||||
);
|
||||
|
||||
@@ -367,7 +418,7 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
|
||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
});
|
||||
|
||||
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
@@ -399,8 +450,8 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
|
||||
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
|
||||
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
|
||||
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
|
||||
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
|
||||
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
|
||||
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
|
||||
|
||||
@@ -415,7 +466,7 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
|
||||
});
|
||||
|
||||
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
@@ -425,7 +476,12 @@ router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
).get(authReq.user.id) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
|
||||
res.json({
|
||||
settings: {
|
||||
maps_api_key: decrypt_api_key(user.maps_api_key),
|
||||
openweather_api_key: decrypt_api_key(user.openweather_api_key),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => {
|
||||
@@ -470,9 +526,21 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
||||
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(authReq.user.id) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const result = { maps: false, weather: false };
|
||||
const result: {
|
||||
maps: boolean;
|
||||
weather: boolean;
|
||||
maps_details: null | {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
status_text: string | null;
|
||||
error_message: string | null;
|
||||
error_status: string | null;
|
||||
error_raw: string | null;
|
||||
};
|
||||
} = { maps: false, weather: false, maps_details: null };
|
||||
|
||||
if (user.maps_api_key) {
|
||||
const maps_api_key = decrypt_api_key(user.maps_api_key);
|
||||
if (maps_api_key) {
|
||||
try {
|
||||
const mapsRes = await fetch(
|
||||
`https://places.googleapis.com/v1/places:searchText`,
|
||||
@@ -480,22 +548,54 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': user.maps_api_key,
|
||||
'X-Goog-Api-Key': maps_api_key,
|
||||
'X-Goog-FieldMask': 'places.displayName',
|
||||
},
|
||||
body: JSON.stringify({ textQuery: 'test' }),
|
||||
}
|
||||
);
|
||||
result.maps = mapsRes.status === 200;
|
||||
let error_text: string | null = null;
|
||||
let error_json: any = null;
|
||||
if (!result.maps) {
|
||||
try {
|
||||
error_text = await mapsRes.text();
|
||||
try {
|
||||
error_json = JSON.parse(error_text);
|
||||
} catch {
|
||||
error_json = null;
|
||||
}
|
||||
} catch {
|
||||
error_text = null;
|
||||
error_json = null;
|
||||
}
|
||||
}
|
||||
result.maps_details = {
|
||||
ok: result.maps,
|
||||
status: mapsRes.status,
|
||||
status_text: mapsRes.statusText || null,
|
||||
error_message: error_json?.error?.message || null,
|
||||
error_status: error_json?.error?.status || null,
|
||||
error_raw: error_text,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
result.maps = false;
|
||||
result.maps_details = {
|
||||
ok: false,
|
||||
status: null,
|
||||
status_text: null,
|
||||
error_message: err instanceof Error ? err.message : 'Request failed',
|
||||
error_status: 'FETCH_ERROR',
|
||||
error_raw: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (user.openweather_api_key) {
|
||||
const openweather_api_key = decrypt_api_key(user.openweather_api_key);
|
||||
if (openweather_api_key) {
|
||||
try {
|
||||
const weatherRes = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
|
||||
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}`
|
||||
);
|
||||
result.weather = weatherRes.status === 200;
|
||||
} catch (err: unknown) {
|
||||
@@ -506,18 +606,58 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'app_url'];
|
||||
|
||||
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
||||
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const { allow_registration, allowed_file_types } = req.body;
|
||||
if (allow_registration !== undefined) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
||||
const { allow_registration, allowed_file_types, require_mfa } = req.body as Record<string, unknown>;
|
||||
|
||||
if (require_mfa === true || require_mfa === 'true') {
|
||||
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
|
||||
if (!(adminMfa?.mfa_enabled === 1)) {
|
||||
return res.status(400).json({
|
||||
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (allowed_file_types !== undefined) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
|
||||
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
if (req.body[key] !== undefined) {
|
||||
let val = String(req.body[key]);
|
||||
if (key === 'require_mfa') {
|
||||
val = req.body[key] === true || val === 'true' ? 'true' : 'false';
|
||||
}
|
||||
// Don't save masked password
|
||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
}
|
||||
}
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'settings.app_update',
|
||||
ip: getClientIp(req),
|
||||
details: {
|
||||
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
|
||||
allowed_file_types_changed: allowed_file_types !== undefined,
|
||||
require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined,
|
||||
},
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -610,10 +750,20 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
const tokenStr = String(code).trim();
|
||||
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const candidateHash = hashBackupCode(tokenStr);
|
||||
const idx = hashes.findIndex(h => h === candidateHash);
|
||||
if (idx === -1) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
}
|
||||
hashes.splice(idx, 1);
|
||||
db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
JSON.stringify(hashes),
|
||||
user.id
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
@@ -667,13 +817,17 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
}
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupHashes = backupCodes.map(hashBackupCode);
|
||||
const enc = encryptMfaSecret(pending);
|
||||
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
enc,
|
||||
JSON.stringify(backupHashes),
|
||||
authReq.user.id
|
||||
);
|
||||
mfaSetupPending.delete(authReq.user.id);
|
||||
res.json({ success: true, mfa_enabled: true });
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||
res.json({ success: true, mfa_enabled: true, backup_codes: backupCodes });
|
||||
});
|
||||
|
||||
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
@@ -681,6 +835,10 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
|
||||
}
|
||||
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||
if (policy?.value === 'true') {
|
||||
return res.status(403).json({ error: 'Two-factor authentication cannot be disabled while it is required for all users.' });
|
||||
}
|
||||
const { password, code } = req.body as { password?: string; code?: string };
|
||||
if (!password || !code) {
|
||||
return res.status(400).json({ error: 'Password and authenticator code are required' });
|
||||
@@ -698,11 +856,56 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
}
|
||||
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
authReq.user.id
|
||||
);
|
||||
mfaSetupPending.delete(authReq.user.id);
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||
res.json({ success: true, mfa_enabled: false });
|
||||
});
|
||||
|
||||
// --- MCP Token Management ---
|
||||
|
||||
router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const tokens = db.prepare(
|
||||
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(authReq.user.id);
|
||||
res.json({ tokens });
|
||||
});
|
||||
|
||||
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Token name is required' });
|
||||
if (name.trim().length > 100) return res.status(400).json({ error: 'Token name must be 100 characters or less' });
|
||||
|
||||
const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(authReq.user.id) as { count: number }).count;
|
||||
if (tokenCount >= 10) return res.status(400).json({ error: 'Maximum of 10 tokens per user reached' });
|
||||
|
||||
const rawToken = 'trek_' + randomBytes(24).toString('hex');
|
||||
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
|
||||
const tokenPrefix = rawToken.slice(0, 13); // "trek_" + 8 hex chars
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
|
||||
).run(authReq.user.id, name.trim(), tokenHash, tokenPrefix);
|
||||
|
||||
const token = db.prepare(
|
||||
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ token: { ...(token as object), raw_token: rawToken } });
|
||||
});
|
||||
|
||||
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { id } = req.params;
|
||||
const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(id, authReq.user.id);
|
||||
if (!token) return res.status(404).json({ error: 'Token not found' });
|
||||
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
|
||||
revokeUserSessions(authReq.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -7,6 +7,10 @@ import fs from 'fs';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import * as scheduler from '../scheduler';
|
||||
import { db, closeDb, reinitialize } from '../db/database';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
|
||||
type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re
|
||||
});
|
||||
|
||||
const stat = fs.statSync(outputPath);
|
||||
const authReq = _req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.create',
|
||||
resource: filename,
|
||||
ip: getClientIp(_req),
|
||||
details: { size: stat.size },
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
backup: {
|
||||
@@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => {
|
||||
res.download(filePath, filename);
|
||||
});
|
||||
|
||||
async function restoreFromZip(zipPath: string, res: Response) {
|
||||
async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) {
|
||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||
try {
|
||||
await fs.createReadStream(zipPath)
|
||||
@@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) {
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
if (audit) {
|
||||
writeAudit({
|
||||
userId: audit.userId,
|
||||
action: audit.source,
|
||||
resource: audit.label,
|
||||
ip: audit.ip,
|
||||
});
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
console.error('Restore error:', err);
|
||||
@@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => {
|
||||
if (!fs.existsSync(zipPath)) {
|
||||
return res.status(404).json({ error: 'Backup not found' });
|
||||
}
|
||||
await restoreFromZip(zipPath, res);
|
||||
const authReq = req as AuthRequest;
|
||||
await restoreFromZip(zipPath, res, {
|
||||
userId: authReq.user.id,
|
||||
ip: getClientIp(req),
|
||||
source: 'backup.restore',
|
||||
label: filename,
|
||||
});
|
||||
});
|
||||
|
||||
const uploadTmp = multer({
|
||||
@@ -206,23 +232,43 @@ const uploadTmp = multer({
|
||||
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const zipPath = req.file.path;
|
||||
await restoreFromZip(zipPath, res);
|
||||
const authReq = req as AuthRequest;
|
||||
const origName = req.file.originalname || 'upload.zip';
|
||||
await restoreFromZip(zipPath, res, {
|
||||
userId: authReq.user.id,
|
||||
ip: getClientIp(req),
|
||||
source: 'backup.upload_restore',
|
||||
label: origName,
|
||||
});
|
||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
||||
});
|
||||
|
||||
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ settings: scheduler.loadSettings() });
|
||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
res.json({ settings: scheduler.loadSettings(), timezone: tz });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] GET auto-settings:', err);
|
||||
res.status(500).json({ error: 'Could not load backup settings' });
|
||||
}
|
||||
});
|
||||
|
||||
function parseIntField(raw: unknown, fallback: number): number {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||
enabled: boolean;
|
||||
interval: string;
|
||||
keep_days: number;
|
||||
hour: number;
|
||||
day_of_week: number;
|
||||
day_of_month: number;
|
||||
} {
|
||||
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
||||
const rawInterval = body.interval;
|
||||
@@ -230,17 +276,11 @@ function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
||||
? rawInterval
|
||||
: 'daily';
|
||||
const rawKeep = body.keep_days;
|
||||
let keepNum: number;
|
||||
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
|
||||
keepNum = Math.floor(rawKeep);
|
||||
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
|
||||
keepNum = parseInt(rawKeep, 10);
|
||||
} else {
|
||||
keepNum = NaN;
|
||||
}
|
||||
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
|
||||
return { enabled, interval, keep_days };
|
||||
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
|
||||
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
|
||||
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
|
||||
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
|
||||
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
|
||||
}
|
||||
|
||||
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
@@ -248,6 +288,13 @@ router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
|
||||
scheduler.saveSettings(settings);
|
||||
scheduler.start();
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.auto_settings',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
|
||||
});
|
||||
res.json({ settings });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] PUT auto-settings:', err);
|
||||
@@ -272,6 +319,13 @@ router.delete('/:filename', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.delete',
|
||||
resource: filename,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
|
||||
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Settlement calculation: who owes whom
|
||||
router.get('/settlement', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||
|
||||
// Calculate net balance per user: positive = is owed money, negative = owes money
|
||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||
|
||||
for (const item of items) {
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
if (members.length === 0) continue;
|
||||
|
||||
const payers = members.filter(m => m.paid);
|
||||
if (payers.length === 0) continue; // no one marked as paid
|
||||
|
||||
const sharePerMember = item.total_price / members.length;
|
||||
const paidPerPayer = item.total_price / payers.length;
|
||||
|
||||
for (const m of members) {
|
||||
if (!balances[m.user_id]) {
|
||||
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
|
||||
}
|
||||
// Everyone owes their share
|
||||
balances[m.user_id].balance -= sharePerMember;
|
||||
// Payers get credited what they paid
|
||||
if (m.paid) balances[m.user_id].balance += paidPerPayer;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
|
||||
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
|
||||
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
|
||||
|
||||
// Sort by amount descending for efficient matching
|
||||
debtors.sort((a, b) => b.amount - a.amount);
|
||||
creditors.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
|
||||
|
||||
let di = 0, ci = 0;
|
||||
while (di < debtors.length && ci < creditors.length) {
|
||||
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
|
||||
if (transfer > 0.01) {
|
||||
flows.push({
|
||||
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
|
||||
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
|
||||
amount: Math.round(transfer * 100) / 100,
|
||||
});
|
||||
}
|
||||
debtors[di].amount -= transfer;
|
||||
creditors[ci].amount -= transfer;
|
||||
if (debtors[di].amount < 0.01) di++;
|
||||
if (creditors[ci].amount < 0.01) ci++;
|
||||
}
|
||||
|
||||
res.json({
|
||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
||||
flows,
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
@@ -420,6 +420,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
||||
const formatted = formatMessage(message);
|
||||
res.status(201).json({ message: formatted });
|
||||
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new chat message
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -77,20 +77,32 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||
type: 'IMAGE',
|
||||
size: 200,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const assets = (data.assets?.items || []).map((a: any) => ({
|
||||
// Paginate through all results (Immich limits per-page to 1000)
|
||||
const allAssets: any[] = [];
|
||||
let page = 1;
|
||||
const pageSize = 1000;
|
||||
while (true) {
|
||||
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||
type: 'IMAGE',
|
||||
size: pageSize,
|
||||
page,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const items = data.assets?.items || [];
|
||||
allAssets.push(...items);
|
||||
if (items.length < pageSize) break; // Last page
|
||||
page++;
|
||||
if (page > 20) break; // Safety limit (20k photos max)
|
||||
}
|
||||
const assets = allAssets.map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
@@ -143,6 +155,14 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
||||
|
||||
res.json({ success: true, added });
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about shared photos
|
||||
if (shared && added > 0) {
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remove a photo from a trip (own photos only)
|
||||
|
||||
@@ -3,6 +3,7 @@ import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { decrypt_api_key } from '../services/apiKeyCrypto';
|
||||
|
||||
interface NominatimResult {
|
||||
osm_type: string;
|
||||
@@ -197,9 +198,10 @@ const router = express.Router();
|
||||
|
||||
function getMapsKey(userId: number): string | null {
|
||||
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
|
||||
if (user?.maps_api_key) return user.maps_api_key;
|
||||
const user_key = decrypt_api_key(user?.maps_api_key);
|
||||
if (user_key) return user_key;
|
||||
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
|
||||
return admin?.maps_api_key || null;
|
||||
return decrypt_api_key(admin?.maps_api_key) || null;
|
||||
}
|
||||
|
||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number }>();
|
||||
@@ -474,4 +476,68 @@ router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve a Google Maps URL to place data (coordinates, name, address)
|
||||
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
let resolvedUrl = url;
|
||||
|
||||
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl)
|
||||
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
}
|
||||
|
||||
// Extract coordinates from Google Maps URL patterns:
|
||||
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
|
||||
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
|
||||
let lat: number | null = null;
|
||||
let lng: number | null = null;
|
||||
let placeName: string | null = null;
|
||||
|
||||
// Pattern: /@lat,lng
|
||||
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
|
||||
|
||||
// Pattern: !3dlat!4dlng (Google Maps data params)
|
||||
if (!lat) {
|
||||
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
||||
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
|
||||
}
|
||||
|
||||
// Pattern: ?q=lat,lng or &q=lat,lng
|
||||
if (!lat) {
|
||||
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||||
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
|
||||
}
|
||||
|
||||
// Extract place name from URL path: /place/Place+Name/@...
|
||||
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
||||
if (placeMatch) {
|
||||
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
|
||||
return res.status(400).json({ error: 'Could not extract coordinates from URL' });
|
||||
}
|
||||
|
||||
// Reverse geocode to get address
|
||||
const nominatimRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
|
||||
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
|
||||
);
|
||||
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
|
||||
|
||||
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
||||
const address = nominatim.display_name || null;
|
||||
|
||||
res.json({ lat, lng, name, address });
|
||||
} catch (err: unknown) {
|
||||
console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err);
|
||||
res.status(400).json({ error: 'Failed to resolve URL' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
58
server/src/routes/notifications.ts
Normal file
58
server/src/routes/notifications.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { testSmtp } from '../services/notifications';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get user's notification preferences
|
||||
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
if (!prefs) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
|
||||
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
}
|
||||
res.json({ preferences: prefs });
|
||||
});
|
||||
|
||||
// Update user's notification preferences
|
||||
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
||||
|
||||
// Ensure row exists
|
||||
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
if (!existing) {
|
||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE notification_preferences SET
|
||||
notify_trip_invite = COALESCE(?, notify_trip_invite),
|
||||
notify_booking_change = COALESCE(?, notify_booking_change),
|
||||
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
|
||||
notify_webhook = COALESCE(?, notify_webhook)
|
||||
WHERE user_id = ?`).run(
|
||||
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
|
||||
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
|
||||
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
|
||||
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
|
||||
authReq.user.id
|
||||
);
|
||||
|
||||
const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
||||
res.json({ preferences: prefs });
|
||||
});
|
||||
|
||||
// Admin: test SMTP configuration
|
||||
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
|
||||
const { email } = req.body;
|
||||
const result = await testSmtp(email || authReq.user.email);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -24,6 +24,9 @@ interface OidcUserInfo {
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
groups?: string[];
|
||||
roles?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
@@ -41,7 +44,7 @@ setInterval(() => {
|
||||
}
|
||||
}, AUTH_CODE_CLEANUP);
|
||||
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string }>();
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -85,6 +88,23 @@ function generateToken(user: { id: number; username: string; email: string; role
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user should be admin based on OIDC claims
|
||||
// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins")
|
||||
function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' {
|
||||
if (isFirstUser) return 'admin';
|
||||
const adminValue = process.env.OIDC_ADMIN_VALUE;
|
||||
if (!adminValue) return 'user'; // No claim mapping configured
|
||||
const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups';
|
||||
const claimData = userInfo[claimKey];
|
||||
if (Array.isArray(claimData)) {
|
||||
return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user';
|
||||
}
|
||||
if (typeof claimData === 'string') {
|
||||
return claimData === adminValue ? 'admin' : 'user';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
|
||||
function frontendUrl(path: string): string {
|
||||
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
|
||||
return base + path;
|
||||
@@ -104,8 +124,9 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
|
||||
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
|
||||
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
@@ -190,18 +211,35 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
if (!user.oidc_sub) {
|
||||
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
||||
}
|
||||
// Update role based on OIDC claims on every login (if claim mapping is configured)
|
||||
if (process.env.OIDC_ADMIN_VALUE) {
|
||||
const newRole = resolveOidcRole(userInfo, false);
|
||||
if (user.role !== newRole) {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
||||
user = { ...user, role: newRole } as User;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
const isFirstUser = userCount === 0;
|
||||
|
||||
if (!isFirstUser) {
|
||||
let validInvite: any = null;
|
||||
if (pending.inviteToken) {
|
||||
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken);
|
||||
if (validInvite) {
|
||||
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null;
|
||||
if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFirstUser && !validInvite) {
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||
if (setting?.value === 'false') {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
|
||||
}
|
||||
}
|
||||
|
||||
const role = isFirstUser ? 'admin' : 'user';
|
||||
const role = resolveOidcRole(userInfo, isFirstUser);
|
||||
const randomPass = crypto.randomBytes(32).toString('hex');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hash = bcrypt.hashSync(randomPass, 10);
|
||||
@@ -214,6 +252,15 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(username, email, hash, role, sub, config.issuer);
|
||||
|
||||
if (validInvite) {
|
||||
const updated = db.prepare(
|
||||
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)'
|
||||
).run(validInvite.id);
|
||||
if (updated.changes === 0) {
|
||||
console.warn(`[OIDC] Invite token ${pending.inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
|
||||
}
|
||||
}
|
||||
|
||||
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,53 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
// Bulk import packing items (must be before /:id)
|
||||
router.post('/import', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { items } = req.body; // [{ name, category?, quantity? }]
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
const created: any[] = [];
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const item of items) {
|
||||
if (!item.name?.trim()) continue;
|
||||
const checked = item.checked ? 1 : 0;
|
||||
const weight = item.weight_grams ? parseInt(item.weight_grams) || null : null;
|
||||
// Resolve bag by name if provided
|
||||
let bagId = null;
|
||||
if (item.bag?.trim()) {
|
||||
const bagName = item.bag.trim();
|
||||
const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
bagId = existing.id;
|
||||
} else {
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||
const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c;
|
||||
const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]);
|
||||
bagId = newBag.lastInsertRowid;
|
||||
}
|
||||
}
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
|
||||
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
res.status(201).json({ items: created, count: created.length });
|
||||
for (const item of created) {
|
||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
@@ -231,6 +278,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
||||
|
||||
res.json({ assignees: rows });
|
||||
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify newly assigned users
|
||||
if (Array.isArray(user_ids) && user_ids.length > 0) {
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
for (const uid of user_ids) {
|
||||
if (uid !== authReq.user.id) {
|
||||
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import multer from 'multer';
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requireTripAccess } from '../middleware/tripAccess';
|
||||
@@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { AuthRequest, Place } from '../types';
|
||||
|
||||
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
interface PlaceWithCategory extends Place {
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
@@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Import places from GPX file (must be before /:id)
|
||||
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
|
||||
const { tripId } = req.params;
|
||||
const file = (req as any).file;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const xml = file.buffer.toString('utf-8');
|
||||
|
||||
const parseCoords = (attrs: string): { lat: number; lng: number } | null => {
|
||||
const latMatch = attrs.match(/lat=["']([^"']+)["']/i);
|
||||
const lonMatch = attrs.match(/lon=["']([^"']+)["']/i);
|
||||
if (!latMatch || !lonMatch) return null;
|
||||
const lat = parseFloat(latMatch[1]);
|
||||
const lng = parseFloat(lonMatch[1]);
|
||||
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null;
|
||||
};
|
||||
|
||||
const stripCdata = (s: string) => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
|
||||
const extractName = (body: string) => { const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
|
||||
const extractDesc = (body: string) => { const m = body.match(/<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null };
|
||||
|
||||
const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = [];
|
||||
|
||||
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||
const wptRegex = /<wpt\s([^>]+)>([\s\S]*?)<\/wpt>/gi;
|
||||
let match;
|
||||
while ((match = wptRegex.exec(xml)) !== null) {
|
||||
const coords = parseCoords(match[1]);
|
||||
if (!coords) continue;
|
||||
const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`;
|
||||
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
|
||||
}
|
||||
|
||||
// 2) If no <wpt>, try <rtept> (route points)
|
||||
if (waypoints.length === 0) {
|
||||
const rteptRegex = /<rtept\s([^>]+)>([\s\S]*?)<\/rtept>/gi;
|
||||
while ((match = rteptRegex.exec(xml)) !== null) {
|
||||
const coords = parseCoords(match[1]);
|
||||
if (!coords) continue;
|
||||
const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
|
||||
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) If still nothing, extract track name + start/end points from <trkpt>
|
||||
if (waypoints.length === 0) {
|
||||
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
|
||||
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
|
||||
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
|
||||
const trackPoints: { lat: number; lng: number }[] = [];
|
||||
while ((match = trkptRegex.exec(xml)) !== null) {
|
||||
const coords = parseCoords(match[1]);
|
||||
if (coords) trackPoints.push(coords);
|
||||
}
|
||||
if (trackPoints.length > 0) {
|
||||
const start = trackPoints[0];
|
||||
waypoints.push({ ...start, name: `${trackName} — Start`, description: null });
|
||||
if (trackPoints.length > 1) {
|
||||
const end = trackPoints[trackPoints.length - 1];
|
||||
waypoints.push({ ...end, name: `${trackName} — End`, description: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (waypoints.length === 0) {
|
||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
||||
}
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const created: any[] = [];
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const wp of waypoints) {
|
||||
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
res.status(201).json({ places: created, count: created.length });
|
||||
for (const place of created) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params
|
||||
|
||||
|
||||
@@ -101,6 +101,35 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
res.status(201).json({ reservation });
|
||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new booking
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title, type: type || 'booking' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
// Batch update day_plan_position for multiple reservations (must be before /:id)
|
||||
router.put('/positions', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { positions } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
|
||||
|
||||
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
||||
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
|
||||
for (const item of items) {
|
||||
stmt.run(item.day_plan_position, item.id, tripId);
|
||||
}
|
||||
});
|
||||
updateMany(positions);
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
165
server/src/routes/share.ts
Normal file
165
server/src/routes/share.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { loadTagsByPlaceIds } from '../services/queryHelpers';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create a share link for a trip (owner/member only)
|
||||
router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
|
||||
|
||||
// Check if token already exists
|
||||
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
// Update permissions
|
||||
db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
|
||||
.run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
|
||||
return res.json({ token: existing.token });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(tripId, token, authReq.user.id, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
|
||||
res.status(201).json({ token });
|
||||
});
|
||||
|
||||
// Get share link status
|
||||
router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
|
||||
res.json(row ? { token: row.token, created_at: row.created_at, share_map: !!row.share_map, share_bookings: !!row.share_bookings, share_packing: !!row.share_packing, share_budget: !!row.share_budget, share_collab: !!row.share_collab } : { token: null });
|
||||
});
|
||||
|
||||
// Delete share link
|
||||
router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Public read-only trip data (no auth required)
|
||||
router.get('/shared/:token', (req: Request, res: Response) => {
|
||||
const { token } = req.params;
|
||||
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' });
|
||||
|
||||
const tripId = shareRow.trip_id;
|
||||
|
||||
// Trip
|
||||
const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
// Days with assignments
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
|
||||
const dayIds = days.map(d => d.id);
|
||||
|
||||
let assignments = {};
|
||||
let dayNotes = {};
|
||||
if (dayIds.length > 0) {
|
||||
const ph = dayIds.map(() => '?').join(',');
|
||||
const allAssignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${ph})
|
||||
ORDER BY da.order_index ASC
|
||||
`).all(...dayIds);
|
||||
|
||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const byDay: Record<number, any[]> = {};
|
||||
for (const a of allAssignments as any[]) {
|
||||
if (!byDay[a.day_id]) byDay[a.day_id] = [];
|
||||
byDay[a.day_id].push({
|
||||
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
|
||||
place: {
|
||||
id: a.place_id, name: a.place_name, description: a.place_description,
|
||||
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
||||
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
||||
image_url: a.image_url, transport_mode: a.transport_mode,
|
||||
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
||||
tags: tagsByPlace[a.place_id] || [],
|
||||
}
|
||||
});
|
||||
}
|
||||
assignments = byDay;
|
||||
|
||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
|
||||
const notesByDay: Record<number, any[]> = {};
|
||||
for (const n of allNotes as any[]) {
|
||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||
notesByDay[n.day_id].push(n);
|
||||
}
|
||||
dayNotes = notesByDay;
|
||||
}
|
||||
|
||||
// Places
|
||||
const places = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||
`).all(tripId);
|
||||
|
||||
// Reservations
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
|
||||
|
||||
// Accommodations
|
||||
const accommodations = db.prepare(`
|
||||
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
|
||||
FROM day_accommodations a JOIN places p ON a.place_id = p.id
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId);
|
||||
|
||||
// Packing
|
||||
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId);
|
||||
|
||||
// Budget
|
||||
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
|
||||
|
||||
// Categories
|
||||
const categories = db.prepare('SELECT * FROM categories').all();
|
||||
|
||||
const permissions = {
|
||||
share_map: !!shareRow.share_map,
|
||||
share_bookings: !!shareRow.share_bookings,
|
||||
share_packing: !!shareRow.share_packing,
|
||||
share_budget: !!shareRow.share_budget,
|
||||
share_collab: !!shareRow.share_collab,
|
||||
};
|
||||
|
||||
// Only include data the owner chose to share
|
||||
const collabMessages = permissions.share_collab
|
||||
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId)
|
||||
: [];
|
||||
|
||||
res.json({
|
||||
trip, days, assignments, dayNotes, places, categories, permissions,
|
||||
reservations: permissions.share_bookings ? reservations : [],
|
||||
accommodations: permissions.share_bookings ? accommodations : [],
|
||||
packing: permissions.share_packing ? packing : [],
|
||||
budget: permissions.share_budget ? budget : [],
|
||||
collab: collabMessages,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -284,6 +284,12 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
|
||||
|
||||
// Notify invited user
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
|
||||
});
|
||||
|
||||
@@ -301,4 +307,83 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ICS calendar export
|
||||
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[];
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
|
||||
const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m);
|
||||
const fmtDate = (d: string) => d.replace(/-/g, '');
|
||||
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
|
||||
|
||||
// Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
|
||||
const fmtDateTime = (d: string, refDate?: string) => {
|
||||
if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0];
|
||||
// Time-only: combine with reference date
|
||||
if (refDate && d.match(/^\d{2}:\d{2}/)) {
|
||||
const datePart = refDate.split('T')[0];
|
||||
return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, '');
|
||||
}
|
||||
return d.replace(/[-:]/g, '');
|
||||
};
|
||||
|
||||
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||||
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
|
||||
|
||||
// Trip as all-day event
|
||||
if (trip.start_date && trip.end_date) {
|
||||
const endNext = new Date(trip.end_date + 'T00:00:00');
|
||||
endNext.setDate(endNext.getDate() + 1);
|
||||
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
|
||||
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
// Reservations as events
|
||||
for (const r of reservations) {
|
||||
if (!r.reservation_time) continue;
|
||||
const hasTime = r.reservation_time.includes('T');
|
||||
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
||||
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
|
||||
if (hasTime) {
|
||||
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
|
||||
if (r.reservation_end_time) {
|
||||
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
|
||||
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
|
||||
}
|
||||
} else {
|
||||
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
|
||||
}
|
||||
ics += `SUMMARY:${esc(r.title)}\r\n`;
|
||||
|
||||
let desc = r.type ? `Type: ${r.type}` : '';
|
||||
if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`;
|
||||
if (meta.airline) desc += `\\nAirline: ${meta.airline}`;
|
||||
if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`;
|
||||
if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`;
|
||||
if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`;
|
||||
if (r.notes) desc += `\\n${r.notes}`;
|
||||
if (desc) ics += `DESCRIPTION:${desc}\r\n`;
|
||||
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
ics += 'END:VCALENDAR\r\n';
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`);
|
||||
res.send(ics);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -349,6 +349,11 @@ router.post('/invite', (req: Request, res: Response) => {
|
||||
});
|
||||
} catch { /* websocket not available */ }
|
||||
|
||||
// Notify invited user
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user