mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge remote-tracking branch 'origin/dev' into feat/indonesian-translation
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export const ADDON_IDS = {
|
||||
MCP: 'mcp',
|
||||
PACKING: 'packing',
|
||||
BUDGET: 'budget',
|
||||
DOCUMENTS: 'documents',
|
||||
VACAY: 'vacay',
|
||||
ATLAS: 'atlas',
|
||||
COLLAB: 'collab',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
+19
-2
@@ -32,11 +32,16 @@ import budgetRoutes from './routes/budget';
|
||||
import collabRoutes from './routes/collab';
|
||||
import backupRoutes from './routes/backup';
|
||||
import oidcRoutes from './routes/oidc';
|
||||
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
|
||||
import vacayRoutes from './routes/vacay';
|
||||
import atlasRoutes from './routes/atlas';
|
||||
import memoriesRoutes from './routes/memories/unified';
|
||||
import photoRoutes from './routes/photos';
|
||||
import notificationRoutes from './routes/notifications';
|
||||
import shareRoutes from './routes/share';
|
||||
import journeyRoutes from './routes/journey';
|
||||
import journeyPublicRoutes from './routes/journeyPublic';
|
||||
import publicConfigRoutes from './routes/publicConfig';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { Addon } from './types';
|
||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||
@@ -142,9 +147,10 @@ export function createApp(): express.Application {
|
||||
});
|
||||
}
|
||||
|
||||
// Static: avatars and covers are public
|
||||
// Static: avatars, covers, and journey photos
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
|
||||
|
||||
// Photos require auth or valid share token
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
@@ -189,6 +195,7 @@ export function createApp(): express.Application {
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
|
||||
app.use('/api/config', publicConfigRoutes);
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
@@ -204,7 +211,7 @@ export function createApp(): express.Application {
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{ id: string; name: string; 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
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{
|
||||
@@ -213,6 +220,7 @@ export function createApp(): express.Application {
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
hint?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
@@ -242,6 +250,7 @@ export function createApp(): express.Application {
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
placeholder: f.placeholder || '',
|
||||
hint: f.hint || null,
|
||||
required: !!f.required,
|
||||
secret: !!f.secret,
|
||||
settings_key: f.settings_key || null,
|
||||
@@ -256,7 +265,10 @@ export function createApp(): express.Application {
|
||||
// Addon routes
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/journeys', journeyRoutes);
|
||||
app.use('/api/public/journey', journeyPublicRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
app.use('/api/photos', photoRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
@@ -264,6 +276,11 @@ export function createApp(): express.Application {
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke)
|
||||
app.use('/', oauthPublicRouter);
|
||||
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
|
||||
app.use('/api/oauth', oauthApiRouter);
|
||||
|
||||
// MCP endpoint
|
||||
app.post('/mcp', mcpHandler);
|
||||
app.get('/mcp', mcpHandler);
|
||||
|
||||
@@ -98,3 +98,15 @@ if (_encryptionKey) {
|
||||
}
|
||||
|
||||
export const ENCRYPTION_KEY = _encryptionKey;
|
||||
|
||||
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
|
||||
// selects one. Only applies when the user has no saved language preference.
|
||||
// Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
|
||||
// Kept duplicated here because server and client are separate npm packages.
|
||||
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
|
||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE || 'en';
|
||||
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
||||
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||
}
|
||||
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
||||
|
||||
+728
-2
@@ -19,7 +19,8 @@ function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
const migrations: Array<() => void> = [
|
||||
type Migration = (() => void) | { raw: () => void };
|
||||
const migrations: Migration[] = [
|
||||
() => db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'),
|
||||
() => db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'),
|
||||
() => db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'),
|
||||
@@ -884,13 +885,738 @@ function runMigrations(db: Database.Database): void {
|
||||
ins.run(r.trip_id, r.category, idx++);
|
||||
}
|
||||
},
|
||||
// Migration: Naver list import addon (default off)
|
||||
() => {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13);
|
||||
} catch (err: any) {
|
||||
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||
}
|
||||
},
|
||||
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
client_secret_hash TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL DEFAULT '[]',
|
||||
allowed_scopes TEXT NOT NULL DEFAULT '[]',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_consents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
scopes TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(client_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
access_token_hash TEXT UNIQUE NOT NULL,
|
||||
refresh_token_hash TEXT UNIQUE NOT NULL,
|
||||
scopes TEXT NOT NULL DEFAULT '[]',
|
||||
access_token_expires_at DATETIME NOT NULL,
|
||||
refresh_token_expires_at DATETIME NOT NULL,
|
||||
revoked_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash);
|
||||
`);
|
||||
},
|
||||
// Migration: Refresh-token rotation chain tracking for replay detection
|
||||
() => {
|
||||
db.exec(`
|
||||
ALTER TABLE oauth_tokens ADD COLUMN parent_token_id INTEGER REFERENCES oauth_tokens(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id);
|
||||
`);
|
||||
},
|
||||
// Migration: Public client support for browser-initiated dynamic registration (DCR)
|
||||
() => {
|
||||
db.exec(`
|
||||
ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui';
|
||||
`);
|
||||
},
|
||||
// Migration: Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients
|
||||
// (must run outside a transaction because PRAGMA foreign_keys cannot change mid-transaction)
|
||||
{
|
||||
raw: () => {
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
client_secret_hash TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL DEFAULT '[]',
|
||||
allowed_scopes TEXT NOT NULL DEFAULT '[]',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_public INTEGER NOT NULL DEFAULT 0,
|
||||
created_via TEXT NOT NULL DEFAULT 'settings_ui'
|
||||
)
|
||||
`);
|
||||
db.exec(`INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`);
|
||||
db.exec(`DROP TABLE oauth_clients`);
|
||||
db.exec(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`);
|
||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`);
|
||||
})();
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
},
|
||||
},
|
||||
// Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos
|
||||
() => {
|
||||
const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'hint')) {
|
||||
db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`);
|
||||
}
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES
|
||||
('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3)
|
||||
`);
|
||||
db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`);
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES
|
||||
('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4)
|
||||
`);
|
||||
db.exec(`
|
||||
UPDATE photo_provider_fields
|
||||
SET hint = 'providerUrlHintSynology'
|
||||
WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url'
|
||||
`);
|
||||
},
|
||||
// Migration 84: Journey addon — trip tracking & travel journal
|
||||
() => {
|
||||
// Register addon (disabled by default — opt-in)
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order)
|
||||
VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35)
|
||||
`).run();
|
||||
|
||||
// Core journey table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
cover_image TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
is_public INTEGER NOT NULL DEFAULT 0,
|
||||
public_token TEXT UNIQUE,
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Check-ins — visited locations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_checkins (
|
||||
id TEXT PRIMARY KEY,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
address TEXT,
|
||||
country_code TEXT,
|
||||
notes TEXT,
|
||||
checked_in_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Journal entries — daily stories
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
|
||||
entry_date TEXT NOT NULL,
|
||||
title TEXT,
|
||||
body TEXT,
|
||||
mood TEXT,
|
||||
weather TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Photos — local uploads + provider references (Immich/Synology)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_photos (
|
||||
id TEXT PRIMARY KEY,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
|
||||
entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL,
|
||||
storage_type TEXT NOT NULL DEFAULT 'local',
|
||||
asset_id TEXT,
|
||||
file_path TEXT,
|
||||
thumbnail_path TEXT,
|
||||
original_name TEXT,
|
||||
mime_type TEXT,
|
||||
size_bytes INTEGER,
|
||||
caption TEXT,
|
||||
taken_at TEXT,
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// GPS trail points (Dawarich integration)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_location_trail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
altitude REAL,
|
||||
accuracy REAL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'dawarich'
|
||||
)
|
||||
`);
|
||||
|
||||
// Indexes
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at);
|
||||
`);
|
||||
},
|
||||
// Migration 85: Journal — richer entry fields for magazine-style design
|
||||
() => {
|
||||
// Highlight tags (JSON array), visibility control, hero photo, color accent
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); } catch {}
|
||||
try { db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); } catch {}
|
||||
|
||||
// Check-in: allow a single cover photo reference
|
||||
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); } catch {}
|
||||
|
||||
// Photos: add caption edit timestamp for gallery ordering
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); } catch {}
|
||||
},
|
||||
// Migration 86: Journey multi-trip support + sharing/collaboration
|
||||
() => {
|
||||
// Junction table: journey can include multiple trips
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_trips (
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
PRIMARY KEY (journey_id, trip_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)');
|
||||
|
||||
// Sharing: invite users to a journey
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
UNIQUE(journey_id, user_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)');
|
||||
|
||||
// author tracking on entries and checkins
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
||||
},
|
||||
// Migration 87: Journey rebuild — new schema with trip sync
|
||||
() => {
|
||||
// Migrate existing data from old tables into backup, then rebuild
|
||||
const hasOldJourneys = db.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='journeys'"
|
||||
).get();
|
||||
|
||||
let oldJourneys: any[] = [];
|
||||
let oldEntries: any[] = [];
|
||||
let oldPhotos: any[] = [];
|
||||
|
||||
if (hasOldJourneys) {
|
||||
// Save existing data before dropping
|
||||
try { oldJourneys = db.prepare('SELECT * FROM journeys').all(); } catch {}
|
||||
try { oldEntries = db.prepare('SELECT * FROM journey_entries').all(); } catch {}
|
||||
try { oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); } catch {}
|
||||
|
||||
// Drop all old journey tables
|
||||
db.exec('DROP TABLE IF EXISTS journey_location_trail');
|
||||
db.exec('DROP TABLE IF EXISTS journey_photos');
|
||||
db.exec('DROP TABLE IF EXISTS journey_entries');
|
||||
db.exec('DROP TABLE IF EXISTS journey_checkins');
|
||||
db.exec('DROP TABLE IF EXISTS journey_members');
|
||||
db.exec('DROP TABLE IF EXISTS journey_trips');
|
||||
db.exec('DROP TABLE IF EXISTS journeys');
|
||||
}
|
||||
|
||||
// New schema
|
||||
db.exec(`
|
||||
CREATE TABLE journeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
cover_gradient TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_trips (
|
||||
journey_id INTEGER NOT NULL,
|
||||
trip_id INTEGER NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (journey_id, trip_id),
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id INTEGER NOT NULL,
|
||||
source_trip_id INTEGER,
|
||||
source_place_id INTEGER,
|
||||
author_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
story TEXT,
|
||||
entry_date TEXT NOT NULL,
|
||||
entry_time TEXT,
|
||||
location_name TEXT,
|
||||
location_lat REAL,
|
||||
location_lng REAL,
|
||||
mood TEXT,
|
||||
weather TEXT,
|
||||
tags TEXT,
|
||||
visibility TEXT DEFAULT 'private',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
caption TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_contributors (
|
||||
journey_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (journey_id, user_id),
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Indexes
|
||||
db.exec(`
|
||||
CREATE INDEX idx_journeys_user ON journeys(user_id);
|
||||
CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date);
|
||||
CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id);
|
||||
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
|
||||
CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id);
|
||||
CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id);
|
||||
`);
|
||||
|
||||
// Re-import old data if it existed
|
||||
if (oldJourneys.length > 0) {
|
||||
const ts = Date.now();
|
||||
const journeyIdMap = new Map<string, number>(); // old TEXT id -> new INTEGER id
|
||||
|
||||
for (const j of oldJourneys) {
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
j.user_id,
|
||||
j.title || 'Untitled Journey',
|
||||
j.description || null,
|
||||
j.status || 'draft',
|
||||
j.created_at ? new Date(j.created_at).getTime() : ts,
|
||||
j.updated_at ? new Date(j.updated_at).getTime() : ts
|
||||
);
|
||||
journeyIdMap.set(j.id, Number(res.lastInsertRowid));
|
||||
|
||||
// Add owner as contributor
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at)
|
||||
VALUES (?, ?, 'owner', ?)
|
||||
`).run(Number(res.lastInsertRowid), j.user_id, ts);
|
||||
|
||||
// Link trip if old journey had one
|
||||
if (j.trip_id) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(Number(res.lastInsertRowid), j.trip_id, ts);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate entries
|
||||
const entryIdMap = new Map<string, number>();
|
||||
for (const e of oldEntries) {
|
||||
const newJourneyId = journeyIdMap.get(e.journey_id);
|
||||
if (!newJourneyId) continue;
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
newJourneyId,
|
||||
e.user_id || oldJourneys.find((j: any) => j.id === e.journey_id)?.user_id || 1,
|
||||
e.title || null,
|
||||
e.body || null,
|
||||
e.entry_date || new Date().toISOString().split('T')[0],
|
||||
e.place_name || null,
|
||||
e.lat || null,
|
||||
e.lng || null,
|
||||
e.mood || null,
|
||||
e.weather || null,
|
||||
e.visibility || 'private',
|
||||
e.sort_order || 0,
|
||||
e.created_at ? new Date(e.created_at).getTime() : ts,
|
||||
e.updated_at ? new Date(e.updated_at).getTime() : ts
|
||||
);
|
||||
entryIdMap.set(e.id, Number(res.lastInsertRowid));
|
||||
}
|
||||
|
||||
// Migrate photos
|
||||
for (const p of oldPhotos) {
|
||||
const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null;
|
||||
if (!newEntryId || !p.file_path) continue;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
newEntryId,
|
||||
p.file_path,
|
||||
p.thumbnail_path || null,
|
||||
p.caption || null,
|
||||
p.sort_order || 0,
|
||||
p.width || null,
|
||||
p.height || null,
|
||||
p.created_at ? new Date(p.created_at).getTime() : ts
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`);
|
||||
}
|
||||
},
|
||||
// Migration 88: Journey photos — provider support (Immich/Synology)
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); } catch {}
|
||||
// file_path was NOT NULL — recreate table to make it nullable
|
||||
const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get();
|
||||
if (hasProvider) {
|
||||
// Already has the column, just ensure file_path is nullable by recreating
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE journey_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'local',
|
||||
asset_id TEXT,
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
file_path TEXT,
|
||||
thumbnail_path TEXT,
|
||||
caption TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos;
|
||||
DROP TABLE journey_photos;
|
||||
ALTER TABLE journey_photos_new RENAME TO journey_photos;
|
||||
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
|
||||
`);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
// Migration 89: Journey cover image
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); } catch {}
|
||||
},
|
||||
// Migration 90: Pros/Cons for journey entries
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); } catch {}
|
||||
},
|
||||
// Migration 91: Journey share tokens
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_share_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER NOT NULL,
|
||||
share_timeline INTEGER DEFAULT 1,
|
||||
share_gallery INTEGER DEFAULT 1,
|
||||
share_map INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)');
|
||||
},
|
||||
// Migration: Vacay week_start setting (0=Sunday, 1=Monday default)
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {}
|
||||
},
|
||||
// Migration: Unified Photo Provider Abstraction Layer (#584)
|
||||
// Central trek_photos registry; trip_photos + journey_photos reference via photo_id
|
||||
() => {
|
||||
// 1. Create the central photo registry
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trek_photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
asset_id TEXT,
|
||||
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
file_path TEXT,
|
||||
thumbnail_path TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)');
|
||||
|
||||
// 2. Migrate trip_photos → trek_photos + photo_id FK
|
||||
const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get();
|
||||
if (tripPhotosExists) {
|
||||
// Detect schema variant: old (immich_asset_id) vs new (asset_id + provider)
|
||||
const tpCols = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
|
||||
const tpColNames = new Set(tpCols.map(c => c.name));
|
||||
const hasProvider = tpColNames.has('provider');
|
||||
const assetCol = tpColNames.has('asset_id') ? 'asset_id' : (tpColNames.has('immich_asset_id') ? 'immich_asset_id' : null);
|
||||
const hasAlbumLink = tpColNames.has('album_link_id');
|
||||
|
||||
if (assetCol) {
|
||||
const providerExpr = hasProvider ? 'provider' : "'immich'";
|
||||
// Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider
|
||||
const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'";
|
||||
const sharedExpr = tpColNames.has('shared') ? 'shared' : '1';
|
||||
const addedAtExpr = tpColNames.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
|
||||
const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL';
|
||||
|
||||
// Insert existing trip photo references into trek_photos
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at)
|
||||
SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr}
|
||||
FROM trip_photos
|
||||
WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != ''
|
||||
`);
|
||||
|
||||
// Recreate trip_photos with photo_id FK
|
||||
db.exec(`
|
||||
CREATE TABLE trip_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, photo_id)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at)
|
||||
SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr}
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id
|
||||
`);
|
||||
} else {
|
||||
// No asset column at all — just recreate empty
|
||||
db.exec(`
|
||||
CREATE TABLE trip_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, photo_id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
db.exec('DROP TABLE trip_photos');
|
||||
db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)');
|
||||
}
|
||||
|
||||
// 3. Migrate journey_photos → trek_photos + photo_id FK
|
||||
const journeyPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get();
|
||||
if (journeyPhotosExists) {
|
||||
// Insert provider-based journey photos into trek_photos
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at)
|
||||
SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at
|
||||
FROM journey_photos
|
||||
WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != ''
|
||||
`);
|
||||
// Insert local journey photos into trek_photos (each is unique)
|
||||
db.exec(`
|
||||
INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at)
|
||||
SELECT 'local', file_path, thumbnail_path, width, height, created_at
|
||||
FROM journey_photos
|
||||
WHERE provider = 'local' AND file_path IS NOT NULL
|
||||
`);
|
||||
|
||||
// Recreate journey_photos with photo_id FK
|
||||
db.exec(`
|
||||
CREATE TABLE journey_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
|
||||
caption TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
// Migrate provider photos
|
||||
db.exec(`
|
||||
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
|
||||
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id
|
||||
WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL
|
||||
`);
|
||||
// Migrate local photos (match by file_path)
|
||||
db.exec(`
|
||||
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
|
||||
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path
|
||||
WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL
|
||||
`);
|
||||
db.exec('DROP TABLE journey_photos');
|
||||
db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)');
|
||||
}
|
||||
},
|
||||
// Migration 99: hide_skeletons per-user setting on journey_contributors
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||
},
|
||||
// Migration 100: Idempotency keys for offline mutation replay
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
response_body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
PRIMARY KEY (key, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
for (let i = currentVersion; i < migrations.length; i++) {
|
||||
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
|
||||
try {
|
||||
db.transaction(() => migrations[i]())();
|
||||
const migration = migrations[i];
|
||||
if (typeof migration === 'function') {
|
||||
db.transaction(migration)();
|
||||
} else {
|
||||
migration.raw();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -245,6 +245,7 @@ function createTables(db: Database.Database): void {
|
||||
label TEXT NOT NULL,
|
||||
input_type TEXT NOT NULL DEFAULT 'text',
|
||||
placeholder TEXT,
|
||||
hint TEXT,
|
||||
required INTEGER DEFAULT 0,
|
||||
secret INTEGER DEFAULT 0,
|
||||
settings_key TEXT,
|
||||
|
||||
+15
-7
@@ -1,6 +1,10 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Seeds run at startup before the DB admin panel can be used, so only env vars
|
||||
// are checked here. The granular password_login/password_registration DB toggles
|
||||
// are only relevant after the first user exists; at that point seeds have already
|
||||
// finished and skip via the userCount > 0 guard above.
|
||||
function isOidcOnlyConfigured(): boolean {
|
||||
if (process.env.OIDC_ONLY !== 'true') return false;
|
||||
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
|
||||
@@ -88,7 +92,9 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
];
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
@@ -115,15 +121,17 @@ function seedAddons(db: Database.Database): void {
|
||||
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
|
||||
|
||||
const providerFields = [
|
||||
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
|
||||
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
|
||||
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
|
||||
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com/photo', hint: 'providerUrlHintSynology', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', hint: null, required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_otp', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: null, required: 0, secret: 0, settings_key: null, payload_key: 'synology_otp', sort_order: 3 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'synology_skip_ssl', payload_key: 'synology_skip_ssl', sort_order: 4 },
|
||||
];
|
||||
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const f of providerFields) {
|
||||
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
|
||||
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
|
||||
}
|
||||
console.log('Default addons seeded');
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -48,6 +48,7 @@ const server = app.listen(PORT, () => {
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
|
||||
+185
-48
@@ -4,37 +4,113 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
|
||||
import { User } from '../types';
|
||||
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
|
||||
import { getUserByAccessToken } from '../services/oauthService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
|
||||
interface McpSession {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
userId: number;
|
||||
lastActivity: number;
|
||||
}
|
||||
export { revokeUserSessions, revokeUserSessionsForClient };
|
||||
|
||||
const sessions = new Map<string, McpSession>();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base instructions injected into every MCP session via the initialize response.
|
||||
// Claude and other clients use these as system-level context before any tool call.
|
||||
// Keep this actionable and concise — vague prose doesn't help the model.
|
||||
// ---------------------------------------------------------------------------
|
||||
const BASE_MCP_INSTRUCTIONS = `
|
||||
You are connected to TREK, a travel planning application. Below is a compact reference of the data model, key workflows, and behavioral rules you must follow.
|
||||
|
||||
## Data model
|
||||
|
||||
- **Trip** — top-level container. Has dates, currency, members (owner + collaborators), and optional add-ons.
|
||||
- **Day** — one calendar day within a trip (YYYY-MM-DD). Days are generated automatically when a trip is created with start/end dates.
|
||||
- **Place** — a point of interest (POI) stored in the trip's place pool. A place is NOT on the itinerary until it is assigned to a day.
|
||||
- **Assignment** — links a Place to a Day (ordered, with optional start/end time). This is what builds the daily itinerary.
|
||||
- **Accommodation** — a hotel or rental linked to a Place and a check-in/check-out day range.
|
||||
- **Reservation** — a booking record (flight, train, restaurant, etc.) with confirmation details, linked to a day.
|
||||
- **Day note** — a free-text annotation attached to a day (with optional time label and emoji icon).
|
||||
- **Budget item** — an expense entry for a trip (amount, category, payer, split between members).
|
||||
- **Packing item** — a checklist entry grouped into bags and categories.
|
||||
- **Todo** — a task (not packing-specific) attached to a trip, ordered and togglable.
|
||||
- **Tag** — a label that can be applied to places for filtering.
|
||||
- **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips.
|
||||
- **Atlas** — global travel journal: bucket list, visited countries and regions.
|
||||
- **Vacay** — vacation-day planner that tracks leave across team members and years.
|
||||
|
||||
## Key workflows
|
||||
|
||||
**Discovering trips:** Always call \`list_trips\` first when no trip ID has been provided. Never assume a trip ID.
|
||||
|
||||
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
|
||||
|
||||
**Adding a place to the itinerary (correct order):**
|
||||
1. \`search_place\` — find the real-world POI; note the \`osm_id\` and/or \`google_place_id\` in the result.
|
||||
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
|
||||
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
|
||||
|
||||
**Creating an accommodation:** A place must exist in the trip first. Create the place (or reuse an existing one), then call \`create_accommodation\` with that \`place_id\` and the \`start_day_id\`/\`end_day_id\`.
|
||||
|
||||
**Reordering:** Assignments, todos, packing items, and reservations all support positional reordering via dedicated reorder tools. Always read the current order from \`get_trip_summary\` before reordering.
|
||||
|
||||
## Access rules
|
||||
|
||||
- The authenticated user can only access trips they own or are a member of. Never guess at trip IDs.
|
||||
- Only the trip owner can delete the trip, add members, or remove members.
|
||||
- Deleting a place removes all of its day assignments as well — warn the user before doing this.
|
||||
- Trips created via MCP are capped at 90 days.
|
||||
|
||||
## Dates and times
|
||||
|
||||
- All dates use ISO format: **YYYY-MM-DD**.
|
||||
- Times are strings like **"09:00"** or **"14:30"** (24-hour). Pass \`null\` to clear a time.
|
||||
- When displaying dates to users, use a friendly human-readable format (e.g. "Mon, Apr 14").
|
||||
|
||||
## Add-on features
|
||||
|
||||
The following features are optional and may not be available on every TREK instance. Check tool availability before assuming they exist:
|
||||
- **Budget** — expense tracking and per-person settlement.
|
||||
- **Packing** — checklist with bags, categories, and templates.
|
||||
- **Collab** — shared notes, polls, and chat messages for group trips.
|
||||
- **Atlas** — bucket list and visited-country/region tracking.
|
||||
- **Vacay** — team vacation-day planner with public holiday integration.
|
||||
|
||||
## Behavioral rules
|
||||
|
||||
- Prefer \`get_trip_summary\` over individual list tools when you need a full picture — it is one call instead of many.
|
||||
- Use \`search_place\` before \`create_place\` so the app gets structured POI data (coordinates, address, opening hours). Do not skip this step.
|
||||
- When the user asks to "add X to day Y", resolve both the place (search + create if needed) and the day ID before calling \`assign_place_to_day\`.
|
||||
- Do not batch destructive operations (delete trip, delete day, delete place) without explicit user confirmation for each.
|
||||
- Present budget amounts with the trip's currency. Use \`get_trip_summary\` to read the currency field.
|
||||
- For group trips, always check member IDs via \`list_trip_members\` before calling tools that require a \`userId\` (e.g. budget splits, assignment participants).
|
||||
`.trim();
|
||||
|
||||
const STATIC_TOKEN_DEPRECATION_NOTICE =
|
||||
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
|
||||
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
|
||||
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
|
||||
'The actual tool result follows — answer the user\'s question as well.';
|
||||
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 5;
|
||||
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 20;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
|
||||
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
|
||||
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 300; // requests per minute per user
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
const rateLimitMap = new Map<number, RateLimitEntry>();
|
||||
const rateLimitMap = new Map<string, RateLimitEntry>();
|
||||
|
||||
function isRateLimited(userId: number): boolean {
|
||||
function isRateLimited(userId: number, clientId: string | null): boolean {
|
||||
const key = `${userId}:${clientId ?? 'native'}`;
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(userId);
|
||||
const entry = rateLimitMap.get(key);
|
||||
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||
rateLimitMap.set(userId, { count: 1, windowStart: now });
|
||||
rateLimitMap.set(key, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
@@ -62,43 +138,83 @@ const sessionSweepInterval = setInterval(() => {
|
||||
}
|
||||
}
|
||||
const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
|
||||
for (const [uid, entry] of rateLimitMap) {
|
||||
if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid);
|
||||
for (const [key, entry] of rateLimitMap) {
|
||||
if (entry.windowStart < rateCutoff) rateLimitMap.delete(key);
|
||||
}
|
||||
if (cleaned > 0 || sessions.size > 0) {
|
||||
console.log(`[MCP] Session sweep: cleaned ${cleaned}, active ${sessions.size}`);
|
||||
}
|
||||
}, 10 * 60 * 1000); // sweep every 10 minutes
|
||||
}, 60 * 1000); // sweep every 1 minute
|
||||
|
||||
// Prevent the interval from keeping the process alive if nothing else is running
|
||||
sessionSweepInterval.unref();
|
||||
|
||||
function verifyToken(authHeader: string | undefined): User | null {
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) return null;
|
||||
interface VerifyTokenResult {
|
||||
user: User;
|
||||
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
|
||||
scopes: string[] | null;
|
||||
/** OAuth client_id when authenticated via OAuth 2.1; null otherwise */
|
||||
clientId: string | null;
|
||||
isStaticToken: boolean;
|
||||
}
|
||||
|
||||
// Long-lived MCP API token (trek_...)
|
||||
if (token.startsWith('trek_')) {
|
||||
return verifyMcpToken(token);
|
||||
function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
if (!authHeader) return null;
|
||||
// M8: strictly require "Bearer" scheme (RFC 6750)
|
||||
const spaceIdx = authHeader.indexOf(' ');
|
||||
if (spaceIdx === -1) return null;
|
||||
const scheme = authHeader.slice(0, spaceIdx);
|
||||
const token = authHeader.slice(spaceIdx + 1);
|
||||
if (scheme.toLowerCase() !== 'bearer' || !token) return null;
|
||||
|
||||
// OAuth 2.1 access token (trekoa_...)
|
||||
if (token.startsWith('trekoa_')) {
|
||||
const result = getUserByAccessToken(token);
|
||||
if (!result) return null;
|
||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||
}
|
||||
|
||||
// Short-lived JWT
|
||||
return verifyJwtToken(token);
|
||||
// Long-lived static MCP token (trek_...) — full access + deprecation notice
|
||||
if (token.startsWith('trek_')) {
|
||||
const user = verifyMcpToken(token);
|
||||
if (!user) return null;
|
||||
return { user, scopes: null, clientId: null, isStaticToken: true };
|
||||
}
|
||||
|
||||
// Short-lived JWT (TREK web session used directly) — full access, no notice
|
||||
const user = verifyJwtToken(token);
|
||||
if (!user) return null;
|
||||
return { user, scopes: null, clientId: null, isStaticToken: false };
|
||||
}
|
||||
|
||||
function logToolCallAudit(req: Request, userId: number, clientId: string | null): void {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (body?.method !== 'tools/call') return;
|
||||
const toolName = (body?.params as Record<string, unknown> | undefined)?.name;
|
||||
if (typeof toolName !== 'string') return;
|
||||
writeAudit({
|
||||
userId,
|
||||
action: 'mcp.tool_call',
|
||||
resource: toolName,
|
||||
details: { clientId: clientId ?? 'native' },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
if (!isAddonEnabled('mcp')) {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
res.status(403).json({ error: 'MCP is not enabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = verifyToken(req.headers['authorization']);
|
||||
if (!user) {
|
||||
const tokenResult = verifyToken(req.headers['authorization']);
|
||||
if (!tokenResult) {
|
||||
res.status(401).json({ error: 'Access token required' });
|
||||
return;
|
||||
}
|
||||
const { user, scopes, clientId, isStaticToken } = tokenResult;
|
||||
|
||||
if (isRateLimited(user.id)) {
|
||||
if (isRateLimited(user.id, clientId)) {
|
||||
res.status(429).json({ error: 'Too many requests. Please slow down.' });
|
||||
return;
|
||||
}
|
||||
@@ -116,7 +232,12 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
res.status(403).json({ error: 'Session belongs to a different user' });
|
||||
return;
|
||||
}
|
||||
if (session.clientId !== clientId) {
|
||||
res.status(403).json({ error: 'Session was created with a different OAuth client' });
|
||||
return;
|
||||
}
|
||||
session.lastActivity = Date.now();
|
||||
logToolCallAudit(req, user.id, clientId);
|
||||
try {
|
||||
await session.transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
@@ -140,49 +261,65 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
// Create a new per-user MCP server and session
|
||||
const server = new McpServer({
|
||||
name: 'TREK MCP',
|
||||
version: '1.0.0',
|
||||
capabilities: {
|
||||
resources: { listChanged: true },
|
||||
tools: { listChanged: true },
|
||||
prompts: { listChanged: true },
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'TREK MCP',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
registerResources(server, user.id);
|
||||
registerTools(server, user.id);
|
||||
{
|
||||
capabilities: {
|
||||
resources: { listChanged: true },
|
||||
tools: { listChanged: true },
|
||||
prompts: { listChanged: true },
|
||||
},
|
||||
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
||||
}
|
||||
);
|
||||
// Per-session closure: fires the deprecation notice once, on the first tool call.
|
||||
// Tool results are the only mechanism Claude reliably surfaces to the user;
|
||||
// the instructions field is only background context and won't trigger a proactive warning.
|
||||
let _noticeEmitted = false;
|
||||
const getDeprecationNotice = (): string | null => {
|
||||
if (!isStaticToken || _noticeEmitted) return null;
|
||||
_noticeEmitted = true;
|
||||
return STATIC_TOKEN_DEPRECATION_NOTICE;
|
||||
};
|
||||
|
||||
registerResources(server, user.id, scopes);
|
||||
registerTools(server, user.id, scopes, isStaticToken, getDeprecationNotice);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sid) => {
|
||||
sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
|
||||
console.log(`[MCP] Session ${sid} created for user ${user.id}. Active sessions: ${sessions.size}`);
|
||||
sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now() });
|
||||
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
|
||||
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
|
||||
},
|
||||
onsessionclosed: (sid) => {
|
||||
sessions.delete(sid);
|
||||
},
|
||||
});
|
||||
|
||||
logToolCallAudit(req, user.id, clientId);
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
console.error('[MCP] transport.handleRequest error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal MCP error', detail: String(err) });
|
||||
res.status(500).json({ error: 'Internal MCP error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
|
||||
export function revokeUserSessions(userId: number): void {
|
||||
/** Invalidate all active MCP sessions (call when addon state changes so sessions re-create with updated tools). */
|
||||
export function invalidateMcpSessions(): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
console.log('[MCP] All sessions invalidated due to addon state change');
|
||||
}
|
||||
|
||||
/** Close all active MCP sessions (call during graceful shutdown). */
|
||||
|
||||
+35
-37
@@ -9,12 +9,13 @@ import { listReservations } from '../services/reservationService';
|
||||
import { listNotes as listDayNotes } from '../services/dayNoteService';
|
||||
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
|
||||
import { listItems as listTodoItems } from '../services/todoService';
|
||||
import { listFiles } from '../services/fileService';
|
||||
import { listCategories } from '../services/categoryService';
|
||||
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { canRead, canReadTrips } from './scopes';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
@@ -31,6 +32,16 @@ function accessDenied(uri: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function scopeDenied(uri: string) {
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Insufficient OAuth scope to access this resource' }),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function jsonContent(uri: string, data: unknown) {
|
||||
return {
|
||||
contents: [{
|
||||
@@ -41,9 +52,9 @@ function jsonContent(uri: string, data: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
export function registerResources(server: McpServer, userId: number): void {
|
||||
export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
// List all accessible trips
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trips',
|
||||
'trek://trips',
|
||||
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
|
||||
@@ -54,7 +65,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Single trip detail
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip',
|
||||
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
|
||||
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
|
||||
@@ -67,7 +78,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Days with assigned places
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip-days',
|
||||
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
|
||||
{ description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
|
||||
@@ -81,7 +92,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Places in a trip
|
||||
server.registerResource(
|
||||
if (canRead(scopes, 'places')) server.registerResource(
|
||||
'trip-places',
|
||||
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
|
||||
{ description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
|
||||
@@ -95,7 +106,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Budget items
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
|
||||
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
|
||||
@@ -108,7 +119,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Packing checklist
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
|
||||
'trip-packing',
|
||||
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
|
||||
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
|
||||
@@ -121,7 +132,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Reservations (flights, hotels, restaurants)
|
||||
server.registerResource(
|
||||
if (canRead(scopes, 'reservations')) server.registerResource(
|
||||
'trip-reservations',
|
||||
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
|
||||
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
|
||||
@@ -134,7 +145,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Day notes
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'day-notes',
|
||||
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
|
||||
{ description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
|
||||
@@ -148,7 +159,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Accommodations (hotels, rentals) per trip
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip-accommodations',
|
||||
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
|
||||
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' },
|
||||
@@ -161,7 +172,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Trip members (owner + collaborators)
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip-members',
|
||||
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
|
||||
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
|
||||
@@ -176,7 +187,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Collab notes for a trip
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource(
|
||||
'trip-collab-notes',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
|
||||
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
|
||||
@@ -188,21 +199,8 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
// Trip files (active, not trash)
|
||||
server.registerResource(
|
||||
'trip-files',
|
||||
new ResourceTemplate('trek://trips/{tripId}/files', { list: undefined }),
|
||||
{ description: 'Active files attached to a trip (excludes trash)', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const files = listFiles(id, false);
|
||||
return jsonContent(uri.href, files);
|
||||
}
|
||||
);
|
||||
|
||||
// Trip to-do list
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'todos')) server.registerResource(
|
||||
'trip-todos',
|
||||
new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }),
|
||||
{ description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' },
|
||||
@@ -214,7 +212,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
// All place categories (global, no trip filter)
|
||||
// All place categories (global, no trip filter) — safe for any authenticated session
|
||||
server.registerResource(
|
||||
'categories',
|
||||
'trek://categories',
|
||||
@@ -226,7 +224,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// User's bucket list
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
|
||||
'bucket-list',
|
||||
'trek://bucket-list',
|
||||
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
|
||||
@@ -237,7 +235,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// User's visited countries
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
|
||||
'visited-countries',
|
||||
'trek://visited-countries',
|
||||
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
|
||||
@@ -248,7 +246,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Budget per-person summary
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget-per-person',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
|
||||
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
|
||||
@@ -261,7 +259,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Budget settlement
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget-settlement',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
|
||||
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
|
||||
@@ -274,7 +272,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Packing bags
|
||||
server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
|
||||
'trip-packing-bags',
|
||||
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
|
||||
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
|
||||
@@ -287,7 +285,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// In-app notifications
|
||||
server.registerResource(
|
||||
if (canRead(scopes, 'notifications')) server.registerResource(
|
||||
'notifications-in-app',
|
||||
'trek://notifications/in-app',
|
||||
{ description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' },
|
||||
@@ -298,7 +296,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Atlas stats and regions (addon-gated)
|
||||
if (isAddonEnabled('atlas')) {
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) {
|
||||
server.registerResource(
|
||||
'atlas-stats',
|
||||
'trek://atlas/stats',
|
||||
@@ -321,7 +319,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
|
||||
// Collab polls & messages (addon-gated)
|
||||
if (isAddonEnabled('collab')) {
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
|
||||
server.registerResource(
|
||||
'trip-collab-polls',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
|
||||
@@ -348,7 +346,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
|
||||
// Vacay resources (addon-gated)
|
||||
if (isAddonEnabled('vacay')) {
|
||||
if (isAddonEnabled(ADDON_IDS.VACAY) && canRead(scopes, 'vacay')) {
|
||||
server.registerResource(
|
||||
'vacay-plan',
|
||||
'trek://vacay/plan',
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth 2.1 scope definitions for TREK MCP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SCOPES = {
|
||||
TRIPS_READ: 'trips:read',
|
||||
TRIPS_WRITE: 'trips:write',
|
||||
TRIPS_DELETE: 'trips:delete',
|
||||
TRIPS_SHARE: 'trips:share',
|
||||
PLACES_READ: 'places:read',
|
||||
PLACES_WRITE: 'places:write',
|
||||
ATLAS_READ: 'atlas:read',
|
||||
ATLAS_WRITE: 'atlas:write',
|
||||
PACKING_READ: 'packing:read',
|
||||
PACKING_WRITE: 'packing:write',
|
||||
TODOS_READ: 'todos:read',
|
||||
TODOS_WRITE: 'todos:write',
|
||||
BUDGET_READ: 'budget:read',
|
||||
BUDGET_WRITE: 'budget:write',
|
||||
RESERVATIONS_READ: 'reservations:read',
|
||||
RESERVATIONS_WRITE: 'reservations:write',
|
||||
COLLAB_READ: 'collab:read',
|
||||
COLLAB_WRITE: 'collab:write',
|
||||
NOTIFICATIONS_READ: 'notifications:read',
|
||||
NOTIFICATIONS_WRITE: 'notifications:write',
|
||||
VACAY_READ: 'vacay:read',
|
||||
VACAY_WRITE: 'vacay:write',
|
||||
GEO_READ: 'geo:read',
|
||||
WEATHER_READ: 'weather:read',
|
||||
} as const;
|
||||
|
||||
export type Scope = typeof SCOPES[keyof typeof SCOPES];
|
||||
|
||||
export const ALL_SCOPES: Scope[] = Object.values(SCOPES) as Scope[];
|
||||
|
||||
export interface ScopeInfo {
|
||||
label: string;
|
||||
description: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
|
||||
'trips:read': { label: 'View trips & itineraries', description: 'Read trips, days, day notes, and members', group: 'Trips' },
|
||||
'trips:write': { label: 'Edit trips & itineraries', description: 'Create and update trips, days, notes, and manage members', group: 'Trips' },
|
||||
'trips:delete': { label: 'Delete trips', description: 'Permanently delete entire trips — this action is irreversible', group: 'Trips' },
|
||||
'trips:share': { label: 'Manage share links', description: 'Create, update, and revoke public share links for trips', group: 'Trips' },
|
||||
'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, and categories', group: 'Places' },
|
||||
'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, and tags', group: 'Places' },
|
||||
'atlas:read': { label: 'View Atlas', description: 'Read visited countries, regions, and bucket list', group: 'Atlas' },
|
||||
'atlas:write': { label: 'Manage Atlas', description: 'Mark countries and regions visited, manage bucket list', group: 'Atlas' },
|
||||
'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' },
|
||||
'packing:write': { label: 'Manage packing lists', description: 'Add, update, delete, toggle, and reorder packing items and bags', group: 'Packing' },
|
||||
'todos:read': { label: 'View to-do lists', description: 'Read trip to-do items and category assignees', group: 'To-dos' },
|
||||
'todos:write': { label: 'Manage to-do lists', description: 'Create, update, toggle, delete, and reorder to-do items', group: 'To-dos' },
|
||||
'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' },
|
||||
'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' },
|
||||
'reservations:read': { label: 'View reservations', description: 'Read reservations and accommodation details', group: 'Reservations' },
|
||||
'reservations:write': { label: 'Manage reservations', description: 'Create, update, delete, and reorder reservations', group: 'Reservations' },
|
||||
'collab:read': { label: 'View collaboration', description: 'Read collab notes, polls, and messages', group: 'Collaboration' },
|
||||
'collab:write': { label: 'Manage collaboration', description: 'Create, update, and delete collab notes, polls, and messages', group: 'Collaboration' },
|
||||
'notifications:read': { label: 'View notifications', description: 'Read in-app notifications and unread counts', group: 'Notifications' },
|
||||
'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' },
|
||||
'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' },
|
||||
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
|
||||
'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
|
||||
'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope enforcement helpers
|
||||
// null scopes = static trek_ token = full access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** trips:read OR trips:write OR trips:delete OR trips:share all grant read access to trips */
|
||||
export function canReadTrips(scopes: string[] | null): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.some(s => s === 'trips:read' || s === 'trips:write' || s === 'trips:delete' || s === 'trips:share');
|
||||
}
|
||||
|
||||
/** group:write grants write access; for trips canReadTrips handles read */
|
||||
export function canWrite(scopes: string[] | null, group: string): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.includes(`${group}:write`);
|
||||
}
|
||||
|
||||
/** group:read OR group:write grant read access */
|
||||
export function canRead(scopes: string[] | null, group: string): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.some(s => s === `${group}:read` || s === `${group}:write`);
|
||||
}
|
||||
|
||||
/** trips:delete is a separate scope from trips:write */
|
||||
export function canDeleteTrips(scopes: string[] | null): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.includes('trips:delete');
|
||||
}
|
||||
|
||||
/** trips:share is a separate scope for managing public share links */
|
||||
export function canShareTrips(scopes: string[] | null): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.includes('trips:share');
|
||||
}
|
||||
|
||||
export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } {
|
||||
const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope));
|
||||
return { valid: invalid.length === 0, invalid };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
|
||||
|
||||
export interface McpSession {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
userId: number;
|
||||
/** null = static trek_ token or JWT (full access); string[] = OAuth 2.1 scopes */
|
||||
scopes: string[] | null;
|
||||
/** OAuth 2.1 client_id that owns this session; null for static-token / JWT sessions */
|
||||
clientId: string | null;
|
||||
/** true when authenticated via static trek_ token — triggers deprecation prompt */
|
||||
isStaticToken: boolean;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
export const sessions = new Map<string, McpSession>();
|
||||
|
||||
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
|
||||
export function revokeUserSessions(userId: number): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminate MCP sessions for a specific (user, OAuth client) pair.
|
||||
* Used when an OAuth token or session is revoked so only the affected client's
|
||||
* sessions are closed, not sessions from other clients for the same user. */
|
||||
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId && session.clientId === clientId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -15,34 +15,34 @@ import { registerTripTools } from './tools/trips';
|
||||
import { registerVacayTools } from './tools/vacay';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
|
||||
export function registerTools(server: McpServer, userId: number): void {
|
||||
registerTripTools(server, userId);
|
||||
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
|
||||
registerTripTools(server, userId, scopes, getDeprecationNotice);
|
||||
|
||||
registerPlaceTools(server, userId);
|
||||
registerPlaceTools(server, userId, scopes);
|
||||
|
||||
registerBudgetTools(server, userId);
|
||||
registerBudgetTools(server, userId, scopes);
|
||||
|
||||
registerPackingTools(server, userId);
|
||||
registerPackingTools(server, userId, scopes);
|
||||
|
||||
registerReservationTools(server, userId);
|
||||
registerReservationTools(server, userId, scopes);
|
||||
|
||||
registerDayTools(server, userId);
|
||||
registerDayTools(server, userId, scopes);
|
||||
|
||||
registerAssignmentTools(server, userId);
|
||||
registerAssignmentTools(server, userId, scopes);
|
||||
|
||||
registerTagTools(server, userId);
|
||||
registerTagTools(server, userId, scopes);
|
||||
|
||||
registerMapsWeatherTools(server, userId);
|
||||
registerMapsWeatherTools(server, userId, scopes);
|
||||
|
||||
registerNotificationTools(server, userId);
|
||||
registerNotificationTools(server, userId, scopes);
|
||||
|
||||
registerAtlasTools(server, userId);
|
||||
registerAtlasTools(server, userId, scopes);
|
||||
|
||||
registerCollabTools(server, userId);
|
||||
registerCollabTools(server, userId, scopes);
|
||||
|
||||
registerVacayTools(server, userId);
|
||||
registerVacayTools(server, userId, scopes);
|
||||
|
||||
registerTodoTools(server, userId);
|
||||
registerTodoTools(server, userId, scopes);
|
||||
|
||||
registerMcpPrompts(server, userId);
|
||||
registerMcpPrompts(server, userId, isStaticToken);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { broadcast } from '../../websocket';
|
||||
|
||||
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
|
||||
try {
|
||||
broadcast(tripId, event, payload);
|
||||
broadcast(tripId, event, { ...payload, _source: 'mcp' });
|
||||
} catch (err) {
|
||||
console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerAssignmentTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'places');
|
||||
const W = canWrite(scopes, 'places');
|
||||
|
||||
export function registerAssignmentTools(server: McpServer, userId: number): void {
|
||||
// --- ASSIGNMENTS ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'assign_place_to_day',
|
||||
{
|
||||
description: 'Assign a place to a specific day in a trip.',
|
||||
@@ -42,7 +46,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'unassign_place',
|
||||
{
|
||||
description: 'Remove a place assignment from a day.',
|
||||
@@ -64,7 +68,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_assignment_time',
|
||||
{
|
||||
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
|
||||
@@ -91,7 +95,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'move_assignment',
|
||||
{
|
||||
description: 'Move a place assignment to a different day.',
|
||||
@@ -107,13 +111,15 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
|
||||
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
if (!getDay(newDayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
|
||||
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
|
||||
return ok({ assignment: result.assignment });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_assignment_participants',
|
||||
{
|
||||
description: 'Get the list of users participating in a specific place assignment.',
|
||||
@@ -125,12 +131,13 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
|
||||
},
|
||||
async ({ tripId, assignmentId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const participants = getAssignmentParticipants(assignmentId);
|
||||
return ok({ participants });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'set_assignment_participants',
|
||||
{
|
||||
description: 'Set the participants for a place assignment (replaces current list).',
|
||||
@@ -144,6 +151,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
|
||||
async ({ tripId, assignmentId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const participants = setAssignmentParticipants(assignmentId, userIds);
|
||||
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
|
||||
return ok({ participants });
|
||||
@@ -152,7 +160,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
|
||||
|
||||
// --- REORDER ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'reorder_day_assignments',
|
||||
{
|
||||
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
|
||||
|
||||
@@ -7,16 +7,23 @@ import {
|
||||
markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem,
|
||||
} from '../../services/atlasService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerAtlasTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'atlas');
|
||||
const W = canWrite(scopes, 'atlas');
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.ATLAS)) return;
|
||||
|
||||
export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
// --- BUCKET LIST ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_bucket_list_item',
|
||||
{
|
||||
description: 'Add a destination to your personal travel bucket list.',
|
||||
@@ -36,7 +43,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_bucket_list_item',
|
||||
{
|
||||
description: 'Remove an item from your travel bucket list.',
|
||||
@@ -55,7 +62,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- ATLAS ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'mark_country_visited',
|
||||
{
|
||||
description: 'Mark a country as visited in your Atlas.',
|
||||
@@ -71,7 +78,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'unmark_country_visited',
|
||||
{
|
||||
description: 'Remove a country from your visited countries in Atlas.',
|
||||
@@ -89,8 +96,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- ATLAS EXPANDED ---
|
||||
|
||||
if (isAddonEnabled('atlas')) {
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_atlas_stats',
|
||||
{
|
||||
description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.',
|
||||
@@ -103,7 +109,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_visited_regions',
|
||||
{
|
||||
description: 'List all manually visited sub-country regions for the current user.',
|
||||
@@ -116,7 +122,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'mark_region_visited',
|
||||
{
|
||||
description: 'Mark a sub-country region as visited.',
|
||||
@@ -135,7 +141,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'unmark_region_visited',
|
||||
{
|
||||
description: 'Remove a region from the visited list.',
|
||||
@@ -151,7 +157,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_country_atlas_places',
|
||||
{
|
||||
description: 'Get places saved in the user\'s atlas for a specific country.',
|
||||
@@ -166,7 +172,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_bucket_list_item',
|
||||
{
|
||||
description: 'Update a bucket list item (notes, name, target date, location).',
|
||||
@@ -188,5 +194,4 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,17 @@ import {
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
export function registerBudgetTools(server: McpServer, userId: number): void {
|
||||
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const W = canWrite(scopes, 'budget');
|
||||
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
// --- BUDGET ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_budget_item',
|
||||
{
|
||||
description: 'Add a budget/expense item to a trip.',
|
||||
@@ -38,7 +44,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_budget_item',
|
||||
{
|
||||
description: 'Delete a budget item from a trip.',
|
||||
@@ -60,7 +66,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- BUDGET (update) ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_budget_item',
|
||||
{
|
||||
description: 'Update an existing budget/expense item in a trip.',
|
||||
@@ -88,7 +94,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- BUDGET ADVANCED ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'set_budget_item_members',
|
||||
{
|
||||
description: 'Set which trip members are splitting a budget item (replaces current member list).',
|
||||
@@ -108,7 +114,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'toggle_budget_member_paid',
|
||||
{
|
||||
description: 'Mark or unmark a member as having paid their share of a budget item.',
|
||||
@@ -128,4 +134,5 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
|
||||
return ok({ member });
|
||||
}
|
||||
);
|
||||
} // isAddonEnabled(BUDGET)
|
||||
}
|
||||
|
||||
@@ -8,16 +8,23 @@ import {
|
||||
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
|
||||
} from '../../services/collabService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerCollabTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'collab');
|
||||
const W = canWrite(scopes, 'collab');
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
|
||||
|
||||
export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
// --- COLLAB NOTES ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_collab_note',
|
||||
{
|
||||
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
|
||||
@@ -40,7 +47,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_collab_note',
|
||||
{
|
||||
description: 'Edit an existing collaborative note on a trip.',
|
||||
@@ -65,7 +72,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_collab_note',
|
||||
{
|
||||
description: 'Delete a collaborative note from a trip.',
|
||||
@@ -87,9 +94,8 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- COLLAB POLLS & CHAT ---
|
||||
|
||||
if (isAddonEnabled('collab')) {
|
||||
server.registerTool(
|
||||
'list_collab_polls',
|
||||
if (R) server.registerTool(
|
||||
'list_collab_polls',
|
||||
{
|
||||
description: 'List all polls for a trip.',
|
||||
inputSchema: {
|
||||
@@ -104,7 +110,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_collab_poll',
|
||||
{
|
||||
description: 'Create a new poll in the collab panel.',
|
||||
@@ -126,7 +132,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'vote_collab_poll',
|
||||
{
|
||||
description: 'Vote on a poll option (or remove vote if already voted for that option).',
|
||||
@@ -146,7 +152,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'close_collab_poll',
|
||||
{
|
||||
description: 'Close a poll so no more votes can be cast.',
|
||||
@@ -166,7 +172,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_collab_poll',
|
||||
{
|
||||
description: 'Delete a poll and all its votes.',
|
||||
@@ -186,7 +192,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_collab_messages',
|
||||
{
|
||||
description: 'List chat messages for a trip (most recent 100, oldest-first).',
|
||||
@@ -203,7 +209,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'send_collab_message',
|
||||
{
|
||||
description: "Send a chat message to a trip's collab channel.",
|
||||
@@ -224,7 +230,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_collab_message',
|
||||
{
|
||||
description: 'Delete a chat message (only the message owner can delete their own messages).',
|
||||
@@ -244,7 +250,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'react_collab_message',
|
||||
{
|
||||
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
|
||||
@@ -264,5 +270,4 @@ export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
return ok({ reactions: result.reactions });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
export function registerDayTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!canWrite(scopes, 'trips')) return;
|
||||
|
||||
export function registerDayTools(server: McpServer, userId: number): void {
|
||||
// --- DAYS ---
|
||||
|
||||
server.registerTool(
|
||||
@@ -75,6 +78,7 @@ export function registerDayTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
deleteDay(dayId);
|
||||
safeBroadcast(tripId, 'day:deleted', { id: dayId });
|
||||
return ok({ success: true });
|
||||
@@ -149,6 +153,7 @@ export function registerDayTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, accommodationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const { linkedReservationId } = deleteAccommodation(accommodationId);
|
||||
safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
|
||||
return ok({ success: true, linkedReservationId });
|
||||
|
||||
@@ -6,11 +6,15 @@ import {
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
ok,
|
||||
} from './_shared';
|
||||
import { canRead } from '../scopes';
|
||||
|
||||
export function registerMapsWeatherTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const canGeo = canRead(scopes, 'geo');
|
||||
const canWeather = canRead(scopes, 'weather');
|
||||
|
||||
export function registerMapsWeatherTools(server: McpServer, userId: number): void {
|
||||
// --- MAPS EXTRAS ---
|
||||
|
||||
server.registerTool(
|
||||
if (canGeo) server.registerTool(
|
||||
'get_place_details',
|
||||
{
|
||||
description: 'Fetch detailed information about a place by its Google Place ID.',
|
||||
@@ -27,7 +31,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (canGeo) server.registerTool(
|
||||
'reverse_geocode',
|
||||
{
|
||||
description: 'Get a human-readable address for given coordinates.',
|
||||
@@ -45,7 +49,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (canGeo) server.registerTool(
|
||||
'resolve_maps_url',
|
||||
{
|
||||
description: 'Resolve a Google Maps share URL to coordinates and place name.',
|
||||
@@ -63,7 +67,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
|
||||
|
||||
// --- WEATHER ---
|
||||
|
||||
server.registerTool(
|
||||
if (canWeather) server.registerTool(
|
||||
'get_weather',
|
||||
{
|
||||
description: 'Get weather forecast for a location and date.',
|
||||
@@ -85,7 +89,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (canWeather) server.registerTool(
|
||||
'get_detailed_weather',
|
||||
{
|
||||
description: 'Get hourly/detailed weather forecast for a location and date.',
|
||||
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerNotificationTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'notifications');
|
||||
const W = canWrite(scopes, 'notifications');
|
||||
|
||||
export function registerNotificationTools(server: McpServer, userId: number): void {
|
||||
// --- NOTIFICATIONS ---
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_notifications',
|
||||
{
|
||||
description: 'List in-app notifications for the current user.',
|
||||
@@ -32,7 +36,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_unread_notification_count',
|
||||
{
|
||||
description: 'Get the number of unread in-app notifications.',
|
||||
@@ -45,7 +49,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'mark_notification_read',
|
||||
{
|
||||
description: 'Mark a single notification as read.',
|
||||
@@ -62,7 +66,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'mark_notification_unread',
|
||||
{
|
||||
description: 'Mark a single notification as unread.',
|
||||
@@ -79,7 +83,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'mark_all_notifications_read',
|
||||
{
|
||||
description: "Mark all of the current user's notifications as read.",
|
||||
|
||||
@@ -16,11 +16,19 @@ import {
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'packing');
|
||||
const W = canWrite(scopes, 'packing');
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
|
||||
|
||||
export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
// --- PACKING ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_packing_item',
|
||||
{
|
||||
description: 'Add an item to the packing checklist for a trip.',
|
||||
@@ -40,7 +48,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'toggle_packing_item',
|
||||
{
|
||||
description: 'Check or uncheck a packing item.',
|
||||
@@ -61,7 +69,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_packing_item',
|
||||
{
|
||||
description: 'Remove an item from the packing checklist.',
|
||||
@@ -83,7 +91,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- PACKING (update) ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_packing_item',
|
||||
{
|
||||
description: 'Rename a packing item or change its category.',
|
||||
@@ -108,7 +116,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- PACKING ADVANCED ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'reorder_packing_items',
|
||||
{
|
||||
description: 'Set the display order of packing items within a trip.',
|
||||
@@ -127,7 +135,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_packing_bags',
|
||||
{
|
||||
description: 'List all packing bags for a trip.',
|
||||
@@ -143,7 +151,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_packing_bag',
|
||||
{
|
||||
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
|
||||
@@ -163,7 +171,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_packing_bag',
|
||||
{
|
||||
description: 'Rename or recolor a packing bag.',
|
||||
@@ -188,7 +196,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_packing_bag',
|
||||
{
|
||||
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
|
||||
@@ -207,7 +215,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'set_bag_members',
|
||||
{
|
||||
description: 'Assign trip members to a packing bag (determines who packs what bag).',
|
||||
@@ -227,7 +235,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_packing_category_assignees',
|
||||
{
|
||||
description: 'Get which trip members are assigned to each packing category.',
|
||||
@@ -243,7 +251,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'set_packing_category_assignees',
|
||||
{
|
||||
description: 'Assign trip members to a packing category.',
|
||||
@@ -263,7 +271,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'apply_packing_template',
|
||||
{
|
||||
description: 'Apply a packing template to a trip (adds items from the template).',
|
||||
@@ -283,7 +291,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'save_packing_template',
|
||||
{
|
||||
description: 'Save the current packing list as a reusable template.',
|
||||
@@ -301,7 +309,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'bulk_import_packing',
|
||||
{
|
||||
description: 'Import multiple packing items at once from a list.',
|
||||
|
||||
@@ -10,11 +10,15 @@ import {
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerPlaceTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'places');
|
||||
const W = canWrite(scopes, 'places');
|
||||
|
||||
export function registerPlaceTools(server: McpServer, userId: number): void {
|
||||
// --- PLACES ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_place',
|
||||
{
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
|
||||
@@ -43,7 +47,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_place',
|
||||
{
|
||||
description: 'Update an existing place in a trip.',
|
||||
@@ -80,7 +84,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_place',
|
||||
{
|
||||
description: 'Delete a place from a trip.',
|
||||
@@ -100,7 +104,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_places',
|
||||
{
|
||||
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
|
||||
@@ -122,7 +126,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- CATEGORIES ---
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_categories',
|
||||
{
|
||||
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
|
||||
@@ -137,7 +141,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
|
||||
|
||||
// --- SEARCH ---
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'search_place',
|
||||
{
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
|
||||
|
||||
@@ -3,8 +3,30 @@ import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { getTripSummary } from '../../services/tripService';
|
||||
import { listItems as listPackingItems } from '../../services/packingService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
export function registerMcpPrompts(server: McpServer, _userId: number): void {
|
||||
export function registerMcpPrompts(server: McpServer, _userId: number, isStaticToken = false): void {
|
||||
if (isStaticToken) {
|
||||
server.registerPrompt(
|
||||
'token_auth_notice',
|
||||
{
|
||||
title: 'Static Token Auth Notice',
|
||||
description: 'Notification that this session uses a static API token which will be deprecated',
|
||||
argsSchema: {},
|
||||
},
|
||||
async () => ({
|
||||
description: 'Static token deprecation notice',
|
||||
messages: [{
|
||||
role: 'user' as const,
|
||||
content: {
|
||||
type: 'text' as const,
|
||||
text: '⚠️ This MCP connection is authenticated with a static API token (trek_…). Static token authentication will be deprecated in a future version of TREK. Please inform the user that they should migrate to OAuth 2.1 by going to Settings → Integrations → MCP → OAuth Clients in TREK and registering an OAuth 2.1 application for their MCP client.',
|
||||
},
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
const userId = _userId;
|
||||
|
||||
server.registerPrompt(
|
||||
@@ -43,7 +65,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
|
||||
}
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerPrompt(
|
||||
'packing-list',
|
||||
{
|
||||
title: 'Packing List',
|
||||
@@ -77,7 +99,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
|
||||
}
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerPrompt(
|
||||
'budget-overview',
|
||||
{
|
||||
title: 'Budget Overview',
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
export function registerReservationTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!canWrite(scopes, 'reservations')) return;
|
||||
|
||||
export function registerReservationTools(server: McpServer, userId: number): void {
|
||||
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { listTags, createTag, updateTag, deleteTag } from '../../services/tagService';
|
||||
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../services/tagService';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerTagTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'places');
|
||||
const W = canWrite(scopes, 'places');
|
||||
|
||||
export function registerTagTools(server: McpServer, userId: number): void {
|
||||
// --- TAGS ---
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_tags',
|
||||
{
|
||||
description: 'List all tags belonging to the current user.',
|
||||
@@ -24,7 +28,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_tag',
|
||||
{
|
||||
description: 'Create a new tag (user-scoped label for places).',
|
||||
@@ -41,7 +45,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_tag',
|
||||
{
|
||||
description: 'Update the name or color of an existing tag.',
|
||||
@@ -54,13 +58,14 @@ export function registerTagTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
async ({ tagId, name, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
|
||||
const tag = updateTag(tagId, name, color);
|
||||
if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
|
||||
return ok({ tag });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_tag',
|
||||
{
|
||||
description: 'Delete a tag (removes it from all places it was attached to).',
|
||||
@@ -71,6 +76,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
async ({ tagId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
|
||||
deleteTag(tagId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -12,11 +12,19 @@ import {
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
export function registerTodoTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'todos');
|
||||
const W = canWrite(scopes, 'todos');
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
|
||||
|
||||
export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
// --- TODOS ---
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_todos',
|
||||
{
|
||||
description: 'List all to-do items for a trip, ordered by position.',
|
||||
@@ -32,7 +40,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_todo',
|
||||
{
|
||||
description: 'Create a new to-do item for a trip.',
|
||||
@@ -56,7 +64,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_todo',
|
||||
{
|
||||
description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.',
|
||||
@@ -88,7 +96,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'toggle_todo',
|
||||
{
|
||||
description: 'Mark a to-do item as checked (done) or unchecked.',
|
||||
@@ -109,7 +117,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_todo',
|
||||
{
|
||||
description: 'Delete a to-do item.',
|
||||
@@ -129,7 +137,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'reorder_todos',
|
||||
{
|
||||
description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.',
|
||||
@@ -147,7 +155,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_todo_category_assignees',
|
||||
{
|
||||
description: 'Get the default assignees configured per to-do category for a trip.',
|
||||
@@ -163,7 +171,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'set_todo_category_assignees',
|
||||
{
|
||||
description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.',
|
||||
|
||||
@@ -13,22 +13,28 @@ import {
|
||||
createOrUpdateShareLink, getShareLink, deleteShareLink,
|
||||
} from '../../services/shareService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { countMessages, listPolls } from '../../services/collabService';
|
||||
import {
|
||||
listItems as listTodoItems,
|
||||
} from '../../services/todoService';
|
||||
import { listFiles } from '../../services/fileService';
|
||||
import {
|
||||
safeBroadcast, MAX_MCP_TRIP_DAYS,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
|
||||
|
||||
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void {
|
||||
const R = canReadTrips(scopes);
|
||||
const W = canWrite(scopes, 'trips');
|
||||
const D = canDeleteTrips(scopes);
|
||||
const S = canShareTrips(scopes);
|
||||
|
||||
export function registerTripTools(server: McpServer, userId: number): void {
|
||||
// --- TRIPS ---
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'create_trip',
|
||||
{
|
||||
description: 'Create a new trip. Returns the created trip with its generated days.',
|
||||
@@ -61,7 +67,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_trip',
|
||||
{
|
||||
description: 'Update an existing trip\'s details.',
|
||||
@@ -94,7 +100,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (D) server.registerTool(
|
||||
'delete_trip',
|
||||
{
|
||||
description: 'Delete a trip. Only the trip owner can delete it.',
|
||||
@@ -111,6 +117,8 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
|
||||
// they are navigation tools that any MCP client needs to discover trip IDs.
|
||||
server.registerTool(
|
||||
'list_trips',
|
||||
{
|
||||
@@ -121,7 +129,15 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ include_archived }) => {
|
||||
const notice = getDeprecationNotice();
|
||||
const trips = listTrips(userId, include_archived ? null : 0);
|
||||
if (notice) return {
|
||||
isError: true as const,
|
||||
content: [
|
||||
{ type: 'text' as const, text: notice },
|
||||
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
|
||||
],
|
||||
};
|
||||
return ok({ trips });
|
||||
}
|
||||
);
|
||||
@@ -131,7 +147,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
server.registerTool(
|
||||
'get_trip_summary',
|
||||
{
|
||||
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, full budget line items with totals, full packing list with checked status, reservations, collab notes, to-do items, files, and collab poll/message counts. Use this as a context loader before planning or modifying a trip.',
|
||||
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
@@ -141,31 +157,59 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const summary = getTripSummary(tripId);
|
||||
if (!summary) return noAccess();
|
||||
const todos = listTodoItems(tripId);
|
||||
const files = listFiles(tripId, false).map((f: any) => ({
|
||||
id: f.id,
|
||||
original_name: f.original_name,
|
||||
mime_type: f.mime_type,
|
||||
file_size: f.file_size,
|
||||
starred: !!f.starred,
|
||||
deleted: !!f.deleted_at,
|
||||
created_at: f.created_at,
|
||||
}));
|
||||
// Addon availability gates
|
||||
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
|
||||
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
|
||||
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
|
||||
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
|
||||
// Core trip data (metadata, days, members, accommodations) is always included
|
||||
// because this tool is always registered and needed for navigation.
|
||||
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
|
||||
const canReadPacking = packingEnabled && canRead(scopes, 'packing');
|
||||
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
|
||||
const canReadTodos = packingEnabled && canRead(scopes, 'todos');
|
||||
const canReadRes = canRead(scopes, 'reservations');
|
||||
const todos = canReadTodos ? listTodoItems(tripId) : [];
|
||||
let pollCount = 0;
|
||||
if (isAddonEnabled('collab')) {
|
||||
pollCount = listPolls(tripId).length;
|
||||
}
|
||||
let messageCount = 0;
|
||||
if (isAddonEnabled('collab')) {
|
||||
if (canReadCollab) {
|
||||
pollCount = listPolls(tripId).length;
|
||||
messageCount = countMessages(tripId);
|
||||
}
|
||||
return ok({ ...summary, todos, files, pollCount, messageCount });
|
||||
const notice = getDeprecationNotice();
|
||||
const data = {
|
||||
...summary,
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : [],
|
||||
todos,
|
||||
pollCount,
|
||||
messageCount,
|
||||
};
|
||||
if (notice) return {
|
||||
isError: true as const,
|
||||
content: [
|
||||
{ type: 'text' as const, text: notice },
|
||||
{ type: 'text' as const, text: JSON.stringify(data, null, 2) },
|
||||
],
|
||||
};
|
||||
return ok({
|
||||
...summary,
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : [],
|
||||
todos,
|
||||
pollCount,
|
||||
messageCount,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_trip_members',
|
||||
{
|
||||
description: 'List all members of a trip (owner + collaborators).',
|
||||
@@ -183,7 +227,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'add_trip_member',
|
||||
{
|
||||
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
|
||||
@@ -210,7 +254,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'remove_trip_member',
|
||||
{
|
||||
description: 'Remove a member from a trip. Only the trip owner can do this.',
|
||||
@@ -232,7 +276,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'copy_trip',
|
||||
{
|
||||
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
|
||||
@@ -255,7 +299,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'export_trip_ics',
|
||||
{
|
||||
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
|
||||
@@ -275,7 +319,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (S) server.registerTool(
|
||||
'get_share_link',
|
||||
{
|
||||
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
|
||||
@@ -291,7 +335,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (S) server.registerTool(
|
||||
'create_share_link',
|
||||
{
|
||||
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
|
||||
@@ -319,7 +363,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (S) server.registerTool(
|
||||
'delete_share_link',
|
||||
{
|
||||
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
|
||||
|
||||
@@ -13,15 +13,20 @@ import {
|
||||
getCountries as getHolidayCountries, getHolidays,
|
||||
} from '../../services/vacayService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
if (isAddonEnabled('vacay')) {
|
||||
server.registerTool(
|
||||
export function registerVacayTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'vacay');
|
||||
const W = canWrite(scopes, 'vacay');
|
||||
|
||||
if (isAddonEnabled(ADDON_IDS.VACAY)) {
|
||||
if (R) server.registerTool(
|
||||
'get_vacay_plan',
|
||||
{
|
||||
description: "Get the current user's active vacation plan (own or joined).",
|
||||
@@ -34,7 +39,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_vacay_plan',
|
||||
{
|
||||
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
|
||||
@@ -55,7 +60,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'set_vacay_color',
|
||||
{
|
||||
description: "Set the current user's color in the vacation plan calendar.",
|
||||
@@ -72,7 +77,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_available_vacay_users',
|
||||
{
|
||||
description: 'List users who can be invited to the current vacation plan.',
|
||||
@@ -86,7 +91,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'send_vacay_invite',
|
||||
{
|
||||
description: 'Invite a user to join the vacation plan by their user ID.',
|
||||
@@ -106,7 +111,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'accept_vacay_invite',
|
||||
{
|
||||
description: 'Accept a pending invitation to join another user\'s vacation plan.',
|
||||
@@ -123,7 +128,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'decline_vacay_invite',
|
||||
{
|
||||
description: 'Decline a pending vacation plan invitation.',
|
||||
@@ -138,7 +143,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'cancel_vacay_invite',
|
||||
{
|
||||
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
|
||||
@@ -155,7 +160,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'dissolve_vacay_plan',
|
||||
{
|
||||
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
|
||||
@@ -169,7 +174,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_vacay_years',
|
||||
{
|
||||
description: 'List calendar years tracked in the current vacation plan.',
|
||||
@@ -183,7 +188,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'add_vacay_year',
|
||||
{
|
||||
description: 'Add a calendar year to the vacation plan.',
|
||||
@@ -200,7 +205,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_vacay_year',
|
||||
{
|
||||
description: 'Remove a calendar year from the vacation plan.',
|
||||
@@ -217,7 +222,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_vacay_entries',
|
||||
{
|
||||
description: 'Get all vacation day entries for a plan and year.',
|
||||
@@ -233,7 +238,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'toggle_vacay_entry',
|
||||
{
|
||||
description: 'Toggle a day on or off as a vacation day for the current user.',
|
||||
@@ -250,7 +255,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'toggle_company_holiday',
|
||||
{
|
||||
description: 'Toggle a date as a company holiday for the whole plan.',
|
||||
@@ -268,7 +273,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'get_vacay_stats',
|
||||
{
|
||||
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
|
||||
@@ -284,7 +289,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_vacay_stats',
|
||||
{
|
||||
description: 'Update the vacation day allowance for a specific user and year.',
|
||||
@@ -302,7 +307,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'add_holiday_calendar',
|
||||
{
|
||||
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
|
||||
@@ -322,7 +327,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'update_holiday_calendar',
|
||||
{
|
||||
description: 'Update label or color for a holiday calendar.',
|
||||
@@ -342,7 +347,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (W) server.registerTool(
|
||||
'delete_holiday_calendar',
|
||||
{
|
||||
description: 'Remove a holiday calendar from the vacation plan.',
|
||||
@@ -359,7 +364,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_holiday_countries',
|
||||
{
|
||||
description: 'List countries available for public holiday calendars.',
|
||||
@@ -373,7 +378,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
if (R) server.registerTool(
|
||||
'list_holidays',
|
||||
{
|
||||
description: 'List public holidays for a country and year.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
||||
import { applyIdempotency } from './idempotency';
|
||||
|
||||
export function extractToken(req: Request): string | null {
|
||||
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
|
||||
@@ -12,6 +13,18 @@ export function extractToken(req: Request): string | null {
|
||||
return (authHeader && authHeader.split(' ')[1]) || null;
|
||||
}
|
||||
|
||||
function verifyJwtAndLoadUser(token: string): User | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role FROM users WHERE id = ?'
|
||||
).get(decoded.id) as User | undefined;
|
||||
return user ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const token = extractToken(req);
|
||||
|
||||
@@ -20,20 +33,31 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role FROM users WHERE id = ?'
|
||||
).get(decoded.id) as User | undefined;
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'User not found', code: 'AUTH_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
(req as AuthRequest).user = user;
|
||||
next();
|
||||
} catch (err: unknown) {
|
||||
const user = verifyJwtAndLoadUser(token);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
(req as AuthRequest).user = user;
|
||||
applyIdempotency(req, res, next, user.id);
|
||||
};
|
||||
|
||||
/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
|
||||
* Used on state-mutating OAuth endpoints (consent POST, client CRUD, session revoke)
|
||||
* to prevent Bearer JWT tokens obtained by other means from managing OAuth clients. */
|
||||
const requireCookieAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const cookieToken = (req as any).cookies?.trek_session;
|
||||
if (!cookieToken) {
|
||||
res.status(401).json({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
const user = verifyJwtAndLoadUser(cookieToken);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
(req as AuthRequest).user = user;
|
||||
next();
|
||||
};
|
||||
|
||||
const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
@@ -74,4 +98,4 @@ const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void
|
||||
next();
|
||||
};
|
||||
|
||||
export { authenticate, optionalAuth, adminOnly, demoUploadBlock };
|
||||
export { authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock };
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { db } from '../db/database';
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
interface IdempotencyRow {
|
||||
status_code: number;
|
||||
response_body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from within `authenticate` after req.user is set.
|
||||
*
|
||||
* For mutating requests carrying X-Idempotency-Key:
|
||||
* - If (key, userId) already stored: replays the cached response.
|
||||
* - Otherwise: wraps res.json to capture and store a successful response.
|
||||
*
|
||||
* Storing happens in idempotency_keys (24h TTL, cleaned by scheduler).
|
||||
*/
|
||||
export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void {
|
||||
if (!MUTATING_METHODS.has(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = req.headers['x-idempotency-key'] as string | undefined;
|
||||
if (!key) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Return cached response if key already processed for this user
|
||||
const existing = db.prepare(
|
||||
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ?'
|
||||
).get(key, userId) as IdempotencyRow | undefined;
|
||||
|
||||
if (existing) {
|
||||
res.status(existing.status_code).json(JSON.parse(existing.response_body));
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap res.json to capture the response on first successful execution
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = function (body: unknown): Response {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(key, userId, req.method, req.path, res.statusCode, JSON.stringify(body), Math.floor(Date.now() / 1000));
|
||||
} catch {
|
||||
// Non-fatal: if storage fails, the request still succeeds
|
||||
}
|
||||
}
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import * as svc from '../services/adminService';
|
||||
import { invalidateMcpSessions } from '../mcp';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -101,13 +102,16 @@ router.get('/oidc', (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
router.put('/oidc', (req: Request, res: Response) => {
|
||||
svc.updateOidcSettings(req.body);
|
||||
const result = svc.updateOidcSettings(req.body);
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json({ error: result.error });
|
||||
}
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.oidc_update',
|
||||
ip: getClientIp(req),
|
||||
details: { oidc_only: !!req.body.oidc_only, issuer_set: !!req.body.issuer },
|
||||
details: { issuer_set: !!req.body.issuer },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -292,6 +296,8 @@ router.put('/addons/:id', (req: Request, res: Response) => {
|
||||
ip: getClientIp(req),
|
||||
details: result.auditDetails,
|
||||
});
|
||||
// Invalidate all MCP sessions so they re-create with the updated addon tool set
|
||||
invalidateMcpSessions();
|
||||
res.json({ addon: result.addon });
|
||||
});
|
||||
|
||||
@@ -307,6 +313,25 @@ router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── OAuth Sessions ─────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/oauth-sessions', (_req: Request, res: Response) => {
|
||||
res.json({ sessions: svc.listOAuthSessions() });
|
||||
});
|
||||
|
||||
router.delete('/oauth-sessions/:id', (req: Request, res: Response) => {
|
||||
const result = svc.revokeOAuthSession(req.params.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.oauth_session.revoke',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
@@ -314,12 +339,8 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
user_id: authReq.user?.id ?? null,
|
||||
username: authReq.user?.username ?? 'unknown',
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.rotate_jwt_secret',
|
||||
target_type: 'system',
|
||||
target_id: null,
|
||||
details: null,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
updateTime,
|
||||
setParticipants,
|
||||
} from '../services/assignmentService';
|
||||
import { onPlaceCreated } from '../services/journeyService';
|
||||
import { AuthRequest } from '../types';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
@@ -45,6 +46,7 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripA
|
||||
const assignment = createAssignment(dayId, place_id, notes);
|
||||
res.status(201).json({ assignment });
|
||||
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceCreated(Number(tripId), Number(place_id)); } catch {}
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as svc from '../services/journeyService';
|
||||
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
||||
import { uploadToImmich } from '../services/memories/immichService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const uploadsBase = path.join(__dirname, '../../uploads/journey');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true });
|
||||
cb(null, uploadsBase);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
cb(null, `${crypto.randomUUID()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ journeys: svc.listJourneys(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { title, subtitle, trip_ids } = req.body || {};
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
const journey = svc.createJourney(authReq.user.id, {
|
||||
title: title.trim(),
|
||||
subtitle,
|
||||
trip_ids: Array.isArray(trip_ids) ? trip_ids : [],
|
||||
});
|
||||
res.status(201).json(journey);
|
||||
});
|
||||
|
||||
router.get('/suggestions', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ trips: svc.getSuggestions(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.get('/available-trips', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ trips: svc.listUserTrips(authReq.user.id) });
|
||||
});
|
||||
|
||||
// ── Entries (prefix /entries — before /:id) ──────────────────────────────
|
||||
|
||||
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string);
|
||||
if (!result) return res.status(404).json({ error: 'Entry not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id, req.headers['x-socket-id'] as string)) {
|
||||
return res.status(404).json({ error: 'Entry not found' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
|
||||
|
||||
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
|
||||
const results: any[] = [];
|
||||
for (const file of files) {
|
||||
const relativePath = `journey/${file.filename}`;
|
||||
const photo = svc.addPhoto(
|
||||
Number(req.params.entryId),
|
||||
authReq.user.id,
|
||||
relativePath,
|
||||
undefined,
|
||||
req.body?.caption
|
||||
);
|
||||
if (photo) {
|
||||
// sync to Immich if connected — update the same photo record
|
||||
try {
|
||||
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
||||
if (immichId) {
|
||||
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
|
||||
photo.provider = 'immich' as any;
|
||||
photo.asset_id = immichId;
|
||||
photo.owner_id = authReq.user.id;
|
||||
}
|
||||
} catch {}
|
||||
results.push(photo);
|
||||
}
|
||||
}
|
||||
|
||||
if (!results.length) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.status(201).json({ photos: results });
|
||||
});
|
||||
|
||||
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { provider, asset_id, asset_ids, caption } = req.body || {};
|
||||
|
||||
// Batch mode: { provider, asset_ids: string[] }
|
||||
if (Array.isArray(asset_ids) && provider) {
|
||||
const added: any[] = [];
|
||||
for (const id of asset_ids) {
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return res.status(201).json({ photos: added, added: added.length });
|
||||
}
|
||||
|
||||
// Single mode (backward compat)
|
||||
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
|
||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||
res.status(201).json(photo);
|
||||
});
|
||||
|
||||
// Link an existing photo to a (different) entry
|
||||
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { photo_id } = req.body || {};
|
||||
if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
|
||||
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
|
||||
if (!result) return res.status(404).json({ error: 'Photo not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
// delete local file
|
||||
if (photo.file_path) {
|
||||
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
|
||||
try { fs.unlinkSync(fullPath); } catch {}
|
||||
}
|
||||
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
|
||||
// photos imported from Immich (no file_path) are just references — don't touch Immich
|
||||
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
|
||||
try {
|
||||
const { getImmichCredentials } = await import('../services/memories/immichService');
|
||||
const creds = getImmichCredentials(authReq.user.id);
|
||||
if (creds) {
|
||||
const { safeFetch } = await import('../utils/ssrfGuard');
|
||||
await safeFetch(`${creds.immich_url}/api/assets`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: [photo.asset_id] }),
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
|
||||
|
||||
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id);
|
||||
if (!data) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.patch('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {});
|
||||
if (!result) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const relativePath = `journey/${req.file.filename}`;
|
||||
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath });
|
||||
if (!result) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Journey not found' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Journey trips ────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/trips', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { trip_id } = req.body || {};
|
||||
if (!trip_id) return res.status(400).json({ error: 'trip_id required' });
|
||||
if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Entries under journey ────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const entries = svc.listEntries(Number(req.params.id), authReq.user.id);
|
||||
if (!entries) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json({ entries });
|
||||
});
|
||||
|
||||
router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { entry_date } = req.body || {};
|
||||
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
|
||||
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body, req.headers['x-socket-id'] as string);
|
||||
if (!entry) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.status(201).json(entry);
|
||||
});
|
||||
|
||||
// ── Contributors ─────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { user_id, role } = req.body || {};
|
||||
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
||||
if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.status(201).json({ success: true });
|
||||
});
|
||||
|
||||
router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { role } = req.body || {};
|
||||
if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── User Preferences ─────────────────────────────────────────────────────
|
||||
|
||||
router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body);
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Share Link ────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const link = getJourneyShareLink(Number(req.params.id));
|
||||
res.json({ link });
|
||||
});
|
||||
|
||||
router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { share_timeline, share_gallery, share_map } = req.body || {};
|
||||
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
deleteJourneyShareLink(Number(req.params.id));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,56 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService';
|
||||
import { streamPhoto } from '../services/memories/photoResolverService';
|
||||
import { streamImmichAsset } from '../services/memories/immichService';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:token', (req: Request, res: Response) => {
|
||||
const data = getPublicJourney(req.params.token);
|
||||
if (!data) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Unified public photo proxy — uses trek_photo_id
|
||||
router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => {
|
||||
const { token, photoId, kind } = req.params;
|
||||
const valid = validateShareTokenForPhoto(token, Number(photoId));
|
||||
if (!valid) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
});
|
||||
|
||||
// Legacy public photo proxy — validates share token instead of auth
|
||||
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
|
||||
const { token, provider, assetId, ownerId, kind } = req.params;
|
||||
|
||||
const valid = validateShareTokenForAsset(token, assetId);
|
||||
if (!valid) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (provider === 'local') {
|
||||
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
|
||||
const resolved = path.resolve(filePath);
|
||||
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
||||
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
return res.sendFile(resolved);
|
||||
}
|
||||
|
||||
const effectiveOwnerId = valid.ownerId || Number(ownerId);
|
||||
if (provider === 'immich') {
|
||||
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
|
||||
} else {
|
||||
try {
|
||||
const { streamSynologyAsset } = await import('../services/memories/synologyService');
|
||||
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
} catch {
|
||||
res.status(404).json({ error: 'Provider not supported' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
autocompletePlaces,
|
||||
} from '../services/mapsService';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -29,6 +30,44 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /autocomplete
|
||||
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { input, lang, locationBias } = req.body;
|
||||
|
||||
if (!input || typeof input !== 'string') {
|
||||
return res.status(400).json({ error: 'Input is required' });
|
||||
}
|
||||
|
||||
if (input.length > 200) {
|
||||
return res.status(400).json({ error: 'Input too long (max 200 chars)' });
|
||||
}
|
||||
|
||||
if (locationBias) {
|
||||
const { low, high } = locationBias;
|
||||
if (!low || !high
|
||||
|| !Number.isFinite(low.lat) || !Number.isFinite(low.lng)
|
||||
|| !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) {
|
||||
return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await autocompletePlaces(
|
||||
authReq.user.id,
|
||||
input,
|
||||
lang as string,
|
||||
locationBias as { low: { lat: number; lng: number }; high: { lat: number; lng: number } } | undefined,
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
const message = err instanceof Error ? err.message : 'Autocomplete error';
|
||||
console.error('Maps autocomplete error:', err);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /details/:placeId
|
||||
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
searchPhotos,
|
||||
streamImmichAsset,
|
||||
listAlbums,
|
||||
getAlbumPhotos,
|
||||
syncAlbumAssets,
|
||||
getAssetInfo,
|
||||
isValidAssetId,
|
||||
@@ -59,10 +60,16 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to } = req.body;
|
||||
const result = await searchPhotos(authReq.user.id, from, to);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
const { from, to, size } = req.body;
|
||||
const pageSize = Math.min(Number(size) || 50, 200);
|
||||
const allAssets: any[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.assets) allAssets.push(...result.assets);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
res.json({ assets: allAssets });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
@@ -113,6 +120,13 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
res.json({ albums: result.albums });
|
||||
});
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await getAlbumPhotos(authReq.user.id, req.params.albumId);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSynologyStatus,
|
||||
testSynologyConnection,
|
||||
listSynologyAlbums,
|
||||
getSynologyAlbumPhotos,
|
||||
syncSynologyAlbumLink,
|
||||
searchSynologyPhotos,
|
||||
getSynologyAssetInfo,
|
||||
@@ -36,12 +37,13 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const synology_url = _parseStringBodyField(body.synology_url);
|
||||
const synology_username = _parseStringBodyField(body.synology_username);
|
||||
const synology_password = _parseStringBodyField(body.synology_password);
|
||||
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
|
||||
|
||||
if (!synology_url || !synology_username) {
|
||||
handleServiceResult(res, fail('URL and username are required', 400));
|
||||
}
|
||||
else {
|
||||
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password));
|
||||
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,10 +53,13 @@ router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const synology_url = _parseStringBodyField(body.synology_url);
|
||||
const synology_username = _parseStringBodyField(body.synology_username);
|
||||
const synology_password = _parseStringBodyField(body.synology_password);
|
||||
const synology_otp = _parseStringBodyField(body.synology_otp);
|
||||
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
|
||||
|
||||
if (!synology_url || !synology_username || !synology_password) {
|
||||
const missingFields: string[] = [];
|
||||
@@ -64,7 +69,7 @@ router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
|
||||
}
|
||||
else{
|
||||
handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password));
|
||||
handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,6 +78,11 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
|
||||
});
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
@@ -86,8 +96,12 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const from = _parseStringBodyField(body.from);
|
||||
const to = _parseStringBodyField(body.to);
|
||||
const offset = _parseNumberBodyField(body.offset, 0);
|
||||
const limit = _parseNumberBodyField(body.limit, 100);
|
||||
let offset = _parseNumberBodyField(body.offset, 0);
|
||||
const page = _parseNumberBodyField(body.page, 1) - 1;
|
||||
let limit = _parseNumberBodyField(body.limit, 100);
|
||||
const size = _parseNumberBodyField(body.size, 0);
|
||||
if(page > 0) offset = page*limit;
|
||||
if(size > 0) limit = size;
|
||||
|
||||
handleServiceResult(res, await searchSynologyPhotos(
|
||||
authReq.user.id,
|
||||
|
||||
@@ -55,8 +55,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
|
||||
const result = await setTripPhotoSharing(
|
||||
tripId,
|
||||
authReq.user.id,
|
||||
req.body?.provider,
|
||||
req.body?.asset_id,
|
||||
Number(req.body?.photo_id),
|
||||
req.body?.shared,
|
||||
);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
@@ -66,7 +65,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
|
||||
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
|
||||
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
|
||||
import { AuthRequest, OptionalAuthRequest } from '../types';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import {
|
||||
validateAuthorizeRequest,
|
||||
createAuthCode,
|
||||
consumeAuthCode,
|
||||
saveConsent,
|
||||
issueTokens,
|
||||
refreshTokens,
|
||||
revokeToken,
|
||||
verifyPKCE,
|
||||
authenticateClient,
|
||||
isValidRedirectUri,
|
||||
listOAuthClients,
|
||||
createOAuthClient,
|
||||
deleteOAuthClient,
|
||||
rotateOAuthClientSecret,
|
||||
listOAuthSessions,
|
||||
revokeSession,
|
||||
AuthorizeParams,
|
||||
} from '../services/oauthService';
|
||||
import { getAppUrl } from '../services/oidcService';
|
||||
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal in-file rate limiter (same pattern as auth.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RateEntry { count: number; first: number; }
|
||||
|
||||
function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) {
|
||||
const store = new Map<string, RateEntry>();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, r] of store) if (now - r.first >= windowMs) store.delete(k);
|
||||
}, windowMs).unref();
|
||||
|
||||
return (req: Request, res: Response, next: () => void): void => {
|
||||
const key = keyFn(req);
|
||||
const now = Date.now();
|
||||
const record = store.get(key);
|
||||
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
|
||||
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
|
||||
return;
|
||||
}
|
||||
if (!record || now - record.first >= windowMs) {
|
||||
store.set(key, { count: 1, first: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
|
||||
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
|
||||
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
||||
const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public router: /.well-known, /oauth/token, /oauth/revoke
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const oauthPublicRouter = express.Router();
|
||||
|
||||
// RFC 8414 discovery document
|
||||
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
|
||||
// M2: return 404 (not 403) so feature presence isn't fingerprinted
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||
res.json({
|
||||
issuer: base,
|
||||
authorization_endpoint: `${base}/oauth/authorize`,
|
||||
token_endpoint: `${base}/oauth/token`,
|
||||
revocation_endpoint: `${base}/oauth/revoke`,
|
||||
registration_endpoint: `${base}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
scope_descriptions: Object.fromEntries(
|
||||
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
// Token endpoint — handles authorization_code and refresh_token grants
|
||||
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
|
||||
// M1: RFC 6749 §5.1 — token responses must not be cached
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Pragma', 'no-cache');
|
||||
|
||||
// Accept both JSON and application/x-www-form-urlencoded
|
||||
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
|
||||
}
|
||||
|
||||
if (!client_id) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
}
|
||||
|
||||
// ---- authorization_code grant ----
|
||||
if (grant_type === 'authorization_code') {
|
||||
if (!code || !redirect_uri || !code_verifier) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
|
||||
}
|
||||
|
||||
const pending = consumeAuthCode(code);
|
||||
|
||||
// H5: collapse all invalid_grant cases to one message; log specifics server-side
|
||||
if (!pending) {
|
||||
writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
if (pending.clientId !== client_id) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
if (pending.redirectUri !== redirect_uri) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
// Verify client secret
|
||||
if (!authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
// Verify PKCE
|
||||
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip });
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
// ---- refresh_token grant ----
|
||||
if (grant_type === 'refresh_token') {
|
||||
if (!refresh_token) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
|
||||
}
|
||||
|
||||
const result = refreshTokens(refresh_token, client_id, client_secret, ip);
|
||||
if (result.error) {
|
||||
if (result.error === 'invalid_client') {
|
||||
logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
}
|
||||
return res.status(result.status || 400).json({
|
||||
error: result.error,
|
||||
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result.tokens);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||
});
|
||||
|
||||
// RFC 7591 Dynamic Client Registration endpoint
|
||||
oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const body: Record<string, unknown> = typeof req.body === 'object' && req.body !== null ? req.body : {};
|
||||
const ip = getClientIp(req);
|
||||
|
||||
const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : [];
|
||||
if (redirectUris.length === 0) {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
|
||||
}
|
||||
|
||||
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
|
||||
const clientName = rawName || 'MCP Client';
|
||||
|
||||
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only
|
||||
const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post';
|
||||
const isPublic = authMethod === 'none';
|
||||
|
||||
// Resolve requested scopes — scope is required; no implicit full-access grant
|
||||
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
|
||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' });
|
||||
}
|
||||
const rawScope = body.scope;
|
||||
const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
|
||||
if (requestedScopes.length === 0) {
|
||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' });
|
||||
}
|
||||
|
||||
const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, {
|
||||
isPublic,
|
||||
createdVia: 'dcr',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error });
|
||||
}
|
||||
|
||||
const client = result.client!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return res.status(201).json({
|
||||
client_id: client.client_id,
|
||||
...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}),
|
||||
client_id_issued_at: now,
|
||||
redirect_uris: client.redirect_uris,
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scope: (client.allowed_scopes as string[]).join(' '),
|
||||
client_name: client.name,
|
||||
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
||||
});
|
||||
});
|
||||
|
||||
// Token revocation endpoint (RFC 7009)
|
||||
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
|
||||
// M2: return 404 when MCP is disabled
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||
const { token, client_id, client_secret } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!token || !client_id) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
|
||||
}
|
||||
|
||||
if (!authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
revokeToken(token, client_id, undefined, ip);
|
||||
// RFC 7009 §2.2: always respond 200 even if token was already revoked or not found
|
||||
return res.status(200).json({});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API router: /api/oauth/* — authenticated endpoints used by the SPA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const oauthApiRouter = express.Router();
|
||||
|
||||
// SPA calls this on page load to validate OAuth params before rendering consent UI
|
||||
oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
|
||||
// M2 / H3: gate by addon; 404 prevents feature fingerprinting for anonymous callers
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const params = req.query as Partial<AuthorizeParams>;
|
||||
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
||||
|
||||
const result = validateAuthorizeRequest(
|
||||
{
|
||||
response_type: params.response_type || '',
|
||||
client_id: params.client_id || '',
|
||||
redirect_uri: params.redirect_uri || '',
|
||||
scope: params.scope || '',
|
||||
state: params.state,
|
||||
code_challenge: params.code_challenge || '',
|
||||
code_challenge_method: params.code_challenge_method || '',
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
|
||||
// (validateAuthorizeRequest already does this, but be explicit here)
|
||||
if (userId === null && result.valid) {
|
||||
return res.json({ valid: result.valid, loginRequired: true });
|
||||
}
|
||||
|
||||
// For unauthenticated error cases return a generic error to prevent oracle enumeration
|
||||
if (userId === null && !result.valid) {
|
||||
return res.json({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
// User submits consent (approve or deny) — requires cookie-only auth (M7)
|
||||
oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => {
|
||||
const { user } = req as AuthRequest;
|
||||
const {
|
||||
client_id, redirect_uri, scope, state,
|
||||
code_challenge, code_challenge_method, approved,
|
||||
} = req.body as {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
approved: boolean;
|
||||
};
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
}
|
||||
|
||||
if (!approved) {
|
||||
// User denied — redirect with error
|
||||
const url = new URL(redirect_uri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
url.searchParams.set('error_description', 'User denied the authorization request');
|
||||
if (state) url.searchParams.set('state', state);
|
||||
return res.json({ redirect: url.toString() });
|
||||
}
|
||||
|
||||
// Re-validate all params (server-side re-check after user action)
|
||||
const params: AuthorizeParams = {
|
||||
response_type: 'code',
|
||||
client_id,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
};
|
||||
|
||||
const validation = validateAuthorizeRequest(params, user.id);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error, error_description: validation.error_description });
|
||||
}
|
||||
|
||||
const scopes = validation.scopes!;
|
||||
|
||||
// Store consent (union with any existing scopes)
|
||||
saveConsent(client_id, user.id, scopes, ip);
|
||||
|
||||
// Issue auth code
|
||||
const code = createAuthCode({
|
||||
clientId: client_id,
|
||||
userId: user.id,
|
||||
redirectUri: redirect_uri,
|
||||
scopes,
|
||||
codeChallenge: code_challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
return res.status(503).json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' });
|
||||
}
|
||||
|
||||
const url = new URL(redirect_uri);
|
||||
url.searchParams.set('code', code);
|
||||
if (state) url.searchParams.set('state', state);
|
||||
|
||||
return res.json({ redirect: url.toString() });
|
||||
});
|
||||
|
||||
// ---- OAuth client CRUD ----
|
||||
|
||||
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
return res.json({ clients: listOAuthClients(user.id) });
|
||||
});
|
||||
|
||||
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const { name, redirect_uris, allowed_scopes } = req.body as {
|
||||
name: string;
|
||||
redirect_uris: string[];
|
||||
allowed_scopes: string[];
|
||||
};
|
||||
|
||||
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.status(201).json(result);
|
||||
});
|
||||
|
||||
oauthApiRouter.post('/clients/:id/rotate', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const result = rotateOAuthClientSecret(user.id, req.params.id, getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.json({ client_secret: result.client_secret });
|
||||
});
|
||||
|
||||
oauthApiRouter.delete('/clients/:id', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const result = deleteOAuthClient(user.id, req.params.id, getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// ---- Active OAuth sessions ----
|
||||
|
||||
oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
return res.json({ sessions: listOAuthSessions(user.id) });
|
||||
});
|
||||
|
||||
oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.json({ success: true });
|
||||
});
|
||||
@@ -15,12 +15,17 @@ import {
|
||||
frontendUrl,
|
||||
getAppUrl,
|
||||
} from '../services/oidcService';
|
||||
import { resolveAuthToggles } from '../services/authService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---- GET /login ----------------------------------------------------------
|
||||
|
||||
router.get('/login', async (req: Request, res: Response) => {
|
||||
if (!resolveAuthToggles().oidc_login) {
|
||||
return res.status(403).json({ error: 'SSO login is disabled.' });
|
||||
}
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
||||
|
||||
@@ -57,6 +62,10 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
// ---- GET /callback -------------------------------------------------------
|
||||
|
||||
router.get('/callback', async (req: Request, res: Response) => {
|
||||
if (!resolveAuthToggles().oidc_login) {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=sso_disabled'));
|
||||
}
|
||||
|
||||
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
|
||||
|
||||
if (oidcError) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService';
|
||||
import { canAccessTrekPhoto } from '../services/memories/helpersService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:id/thumbnail', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photoId = Number(req.params.id);
|
||||
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
|
||||
|
||||
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await streamPhoto(res, authReq.user.id, photoId, 'thumbnail');
|
||||
});
|
||||
|
||||
router.get('/:id/original', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photoId = Number(req.params.id);
|
||||
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
|
||||
|
||||
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await streamPhoto(res, authReq.user.id, photoId, 'original');
|
||||
});
|
||||
|
||||
router.get('/:id/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photoId = Number(req.params.id);
|
||||
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
|
||||
|
||||
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const result = await getPhotoInfo(authReq.user.id, photoId);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
listPlaces,
|
||||
@@ -13,11 +14,14 @@ import {
|
||||
updatePlace,
|
||||
deletePlace,
|
||||
importGpx,
|
||||
importMapFile,
|
||||
importGoogleList,
|
||||
importNaverList,
|
||||
searchPlaceImage,
|
||||
} from '../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||
|
||||
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
const uploadMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -49,29 +53,56 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
|
||||
const place = createPlace(tripId, req.body);
|
||||
res.status(201).json({ place });
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceCreated(Number(tripId), place.id); } catch {}
|
||||
});
|
||||
|
||||
// Import places from GPX file with full track geometry (must be before /:id)
|
||||
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
|
||||
router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const file = (req as any).file;
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const created = importGpx(tripId, file.buffer);
|
||||
if (!created) {
|
||||
const result = importGpx(tripId, file.buffer);
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: created, count: created.length });
|
||||
for (const place of created) {
|
||||
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single('file'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
const { tripId } = req.params;
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
try {
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname);
|
||||
if (result.summary?.totalPlacemarks === 0) {
|
||||
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
|
||||
}
|
||||
|
||||
res.status(201).json(result);
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to import map file';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Import places from a shared Google Maps list URL
|
||||
router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
@@ -89,7 +120,7 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
@@ -99,6 +130,36 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
|
||||
}
|
||||
});
|
||||
|
||||
// Import places from a shared Naver Maps list URL
|
||||
router.post('/import/naver-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!isAddonEnabled('naver_list_import')) {
|
||||
return res.status(403).json({ error: 'Naver list import addon is disabled' });
|
||||
}
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
const result = await importNaverList(tripId, url);
|
||||
|
||||
if ('error' in result) {
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('[Places] Naver list import error:', err instanceof Error ? err.message : err);
|
||||
res.status(400).json({ error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
@@ -142,6 +203,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
|
||||
|
||||
res.json({ place });
|
||||
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceUpdated(place.id); } catch {}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
@@ -151,6 +213,7 @@ router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Respo
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete
|
||||
const deleted = deletePlace(tripId, id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Place not found' });
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { DEFAULT_LANGUAGE } from '../config';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json({ defaultLanguage: DEFAULT_LANGUAGE });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -29,6 +29,13 @@ import {
|
||||
ValidationError,
|
||||
TRIP_SELECT,
|
||||
} from '../services/tripService';
|
||||
import { listDays, listAccommodations } from '../services/dayService';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listItems as listPackingItems } from '../services/packingService';
|
||||
import { listItems as listTodoItems } from '../services/todoService';
|
||||
import { listBudgetItems } from '../services/budgetService';
|
||||
import { listReservations } from '../services/reservationService';
|
||||
import { listFiles } from '../services/fileService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -294,6 +301,41 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Offline bundle ────────────────────────────────────────────────────────
|
||||
// Returns all trip sub-collections in a single request for offline caching.
|
||||
|
||||
router.get('/:id/bundle', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const tripId = req.params.id;
|
||||
|
||||
const trip = getTrip(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { days } = listDays(tripId);
|
||||
const places = listPlaces(String(tripId), {});
|
||||
const packingItems = listPackingItems(tripId);
|
||||
const todoItems = listTodoItems(tripId);
|
||||
const budgetItems = listBudgetItems(tripId);
|
||||
const reservations = listReservations(tripId);
|
||||
const files = listFiles(tripId, false);
|
||||
const accommodations = listAccommodations(tripId);
|
||||
const { owner, members } = listMembers(tripId, trip.user_id);
|
||||
const allMembers = [owner, ...(members || [])].filter(Boolean);
|
||||
|
||||
res.json({
|
||||
trip,
|
||||
days,
|
||||
places,
|
||||
packingItems,
|
||||
todoItems,
|
||||
budgetItems,
|
||||
reservations,
|
||||
files,
|
||||
accommodations,
|
||||
members: allMembers,
|
||||
});
|
||||
});
|
||||
|
||||
// ── ICS calendar export ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
+25
-1
@@ -230,11 +230,35 @@ function startVersionCheck(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours
|
||||
let idempotencyCleanupTask: ScheduledTask | null = null;
|
||||
|
||||
function startIdempotencyCleanup(): void {
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
idempotencyCleanupTask = cron.schedule('0 3 * * *', () => {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 86400;
|
||||
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -7,10 +7,11 @@ 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 } from '../mcp';
|
||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
import { resolveAuthToggles } from './authService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,13 +21,26 @@ export function utcSuffix(ts: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const na = pa[i] || 0, nb = pb[i] || 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -254,16 +268,20 @@ export function updateOidcSettings(data: {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
display_name?: string;
|
||||
oidc_only?: boolean;
|
||||
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_only', data.oidc_only ? 'true' : 'false');
|
||||
set('oidc_discovery_url', data.discovery_url ?? '');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||
@@ -298,21 +316,72 @@ export async function getGithubReleases(perPage: string = '10', page: string = '
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkVersion() {
|
||||
const { version: currentVersion } = require('../../package.json');
|
||||
try {
|
||||
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 { current: currentVersion, latest: currentVersion, update_available: false };
|
||||
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;
|
||||
return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker };
|
||||
} catch {
|
||||
return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker };
|
||||
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> {
|
||||
@@ -330,7 +399,7 @@ export async function checkAndNotifyVersion(): Promise<void> {
|
||||
actorId: null,
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: result.latest as string },
|
||||
params: { version: result.latest },
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore — version check is non-critical
|
||||
@@ -603,6 +672,30 @@ export function deleteMcpToken(id: string) {
|
||||
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 } {
|
||||
|
||||
@@ -71,18 +71,35 @@ setInterval(() => {
|
||||
export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
|
||||
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
|
||||
BD:[88.0,20.7,92.7,26.6],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
|
||||
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
|
||||
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
|
||||
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
|
||||
IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],
|
||||
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],
|
||||
LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5],
|
||||
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],
|
||||
PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2],
|
||||
RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8],
|
||||
BA:[15.7,42.6,19.6,45.3],BD:[88.0,20.7,92.7,26.6],BF:[-5.5,9.4,2.4,15.1],BH:[50.4,25.8,50.7,26.2],BI:[29.0,-4.5,30.8,-2.3],
|
||||
BJ:[0.8,6.2,3.8,12.4],BN:[114.1,4.0,115.4,5.1],BO:[-69.7,-22.9,-57.5,-9.7],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],
|
||||
BG:[22.4,41.2,28.6,44.2],BW:[20.0,-26.9,29.4,-17.8],CA:[-141,41.7,-52.6,83.1],CD:[12.2,-13.5,31.3,5.4],CG:[11.2,-5.0,18.7,3.7],
|
||||
CI:[-8.6,4.3,-2.5,10.7],CL:[-75.6,-55.9,-66.9,-17.5],CM:[8.4,1.7,16.2,13.1],CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],
|
||||
CR:[-85.9,8.0,-82.5,11.2],CU:[-85.0,19.8,-74.1,23.2],CV:[-25.4,14.8,-22.7,17.2],CY:[32.3,34.5,34.1,35.7],HR:[13.5,42.4,19.5,46.6],
|
||||
CZ:[12.1,48.6,18.9,51.1],DJ:[41.8,11.0,43.4,12.7],DK:[8,54.6,15.2,57.8],DO:[-72.0,17.5,-68.3,19.9],EC:[-81.0,-5.0,-75.2,1.5],
|
||||
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],ER:[36.4,12.4,43.1,18.0],ET:[33.0,3.4,47.9,14.9],FI:[20.6,59.8,31.6,70.1],
|
||||
FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],GE:[40.0,41.0,46.7,43.6],GH:[-3.3,4.7,1.2,11.2],GN:[-15.1,7.2,-7.6,12.7],
|
||||
GR:[19.4,34.8,29.7,41.8],GT:[-92.2,13.7,-88.2,17.8],HN:[-89.4,12.9,-83.2,16.5],HT:[-74.5,18.0,-71.6,20.1],HU:[16,45.7,22.9,48.6],
|
||||
IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],
|
||||
IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],JM:[-78.4,17.7,-76.2,18.5],JO:[34.9,29.2,39.3,33.4],
|
||||
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KG:[69.2,39.2,80.3,43.2],KH:[102.3,10.4,107.6,14.7],KR:[126,33.2,129.6,38.6],
|
||||
KW:[46.5,28.5,48.4,30.1],KZ:[50.3,40.6,87.4,55.4],LA:[100.1,13.9,107.7,22.5],LB:[35.1,33.1,36.6,34.7],LK:[79.7,5.9,81.9,9.8],
|
||||
LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],LU:[5.7,49.4,6.5,50.2],LY:[9.5,19.5,25.2,33.3],MA:[-13.2,27.7,-1,35.9],
|
||||
MD:[26.6,45.5,30.2,48.5],ME:[18.4,41.8,20.4,43.6],MG:[43.2,-25.6,50.5,-11.9],MK:[20.5,40.8,23.0,42.4],ML:[-4.8,10.1,4.3,25.0],
|
||||
MM:[92.2,9.8,101.2,28.5],MN:[87.8,41.6,119.9,52.1],MR:[-17.1,14.7,-4.8,27.3],MT:[14.1,35.8,14.6,36.1],MU:[57.3,-20.5,57.8,-19.9],
|
||||
MV:[72.7,-0.7,73.8,7.1],MW:[32.7,-17.1,35.9,-9.4],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MZ:[30.2,-26.9,40.8,-10.5],
|
||||
NA:[11.7,-28.9,25.3,-17.0],NE:[0.2,11.7,15.9,23.5],NI:[-87.7,10.7,-83.1,15.0],NL:[3.4,50.8,7.2,53.5],NP:[80.1,26.4,88.2,30.4],
|
||||
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],OM:[51.9,16.6,59.8,26.4],PA:[-83.0,7.2,-77.2,9.6],PG:[140.8,-11.7,155.7,-1.3],
|
||||
PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],PL:[14.1,49,24.1,54.9],PS:[34.2,29.5,35.6,32.6],
|
||||
PT:[-9.5,36.8,-6.2,42.2],PY:[-62.6,-27.6,-54.3,-19.3],QA:[50.7,24.5,51.6,26.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],
|
||||
RW:[29.0,-2.8,30.9,-1.0],SA:[34.6,16.4,55.7,32.2],SC:[55.3,-9.7,55.8,-3.7],SD:[21.8,3.4,38.6,22.2],SG:[103.6,1.2,104.1,1.5],
|
||||
SI:[13.4,45.4,16.6,46.9],SK:[16.8,47.7,22.6,49.6],SN:[-17.5,12.3,-11.4,15.0],SO:[40.9,-1.7,51.4,11.9],RS:[18.8,42.2,23,46.2],
|
||||
SV:[-90.1,13.2,-87.7,14.5],SY:[35.7,32.3,42.4,37.3],TG:[-0.2,6.1,1.8,11.2],TJ:[67.3,36.7,75.2,41.0],TM:[52.4,35.1,66.7,42.8],
|
||||
TN:[7.5,30.2,11.6,37.5],TT:[-61.9,10.0,-60.5,11.3],TW:[120.1,21.9,122.0,25.3],TZ:[29.3,-11.7,40.4,-1.0],ZA:[16.5,-34.8,32.9,-22.1],
|
||||
SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4],
|
||||
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],
|
||||
UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2],
|
||||
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3],
|
||||
YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6],
|
||||
};
|
||||
|
||||
export const NAME_TO_CODE: Record<string, string> = {
|
||||
@@ -106,26 +123,71 @@ export const NAME_TO_CODE: Record<string, string> = {
|
||||
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
|
||||
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
|
||||
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
|
||||
'georgia':'GE','georgien':'GE','montenegro':'ME','north macedonia':'MK','nordmazedonien':'MK',
|
||||
'macedonia':'MK','bosnia':'BA','bosnia and herzegovina':'BA','bosnien':'BA','kosovo':'XK',
|
||||
'cyprus':'CY','zypern':'CY','malta':'MT','tunisia':'TN','tunesien':'TN','jordan':'JO','jordanien':'JO',
|
||||
'lebanon':'LB','libanon':'LB','ghana':'GH','ethiopia':'ET','athiopien':'ET','tanzania':'TZ','uganda':'UG',
|
||||
'singapore':'SG','taiwan':'TW','nepal':'NP','sri lanka':'LK','cambodia':'KH','kambodscha':'KH',
|
||||
'myanmar':'MM','burma':'MM','laos':'LA','mongolia':'MN','mongolei':'MN','kazakhstan':'KZ','kasachstan':'KZ',
|
||||
'uzbekistan':'UZ','usbekistan':'UZ','kyrgyzstan':'KG','kirgisistan':'KG','tajikistan':'TJ','tadschikistan':'TJ',
|
||||
'turkmenistan':'TM','costa rica':'CR','panama':'PA','ecuador':'EC','uruguay':'UY','cuba':'CU','kuba':'CU',
|
||||
'dominican republic':'DO','dominikanische republik':'DO','jamaica':'JM','haiti':'HT','honduras':'HN',
|
||||
'guatemala':'GT','el salvador':'SV','nicaragua':'NI','bolivia':'BO','bolivia plurinational state of':'BO',
|
||||
'paraguay':'PY','venezuela':'VE','trinidad and tobago':'TT','trinidad':'TT',
|
||||
'oman':'OM','kuwait':'KW','qatar':'QA','bahrain':'BH',
|
||||
'syria':'SY','syrien':'SY','yemen':'YE','jemen':'YE','palestine':'PS','palastina':'PS',
|
||||
'moldova':'MD','republic of moldova':'MD','moldawien':'MD',
|
||||
'libya':'LY','libyen':'LY','sudan':'SD','eritrea':'ER','djibouti':'DJ',
|
||||
'senegal':'SN','cameroon':'CM','kamerun':'CM','ivory coast':'CI','cote d\'ivoire':'CI',
|
||||
'mali':'ML','niger':'NE','burkina faso':'BF','togo':'TG','benin':'BJ','guinea':'GN',
|
||||
'dr congo':'CD','democratic republic of the congo':'CD','republic of the congo':'CG','congo':'CG',
|
||||
'angola':'AO','namibia':'NA','botswana':'BW','zimbabwe':'ZW','zambia':'ZM','malawi':'MW',
|
||||
'mozambique':'MZ','mozambik':'MZ','madagascar':'MG','rwanda':'RW','burundi':'BI',
|
||||
'somalia':'SO','papua new guinea':'PG','brunei':'BN',
|
||||
};
|
||||
|
||||
export const CONTINENT_MAP: Record<string, string> = {
|
||||
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
|
||||
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
|
||||
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
|
||||
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
|
||||
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
|
||||
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
|
||||
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
|
||||
AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
|
||||
BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America',
|
||||
BR:'South America',BE:'Europe',BG:'Europe',BW:'Africa',
|
||||
CA:'North America',CD:'Africa',CG:'Africa',CI:'Africa',CL:'South America',CM:'Africa',CN:'Asia',CO:'South America',
|
||||
CR:'North America',CU:'North America',CV:'Africa',CY:'Europe',HR:'Europe',CZ:'Europe',
|
||||
DJ:'Africa',DK:'Europe',DO:'North America',EC:'South America',EG:'Africa',EE:'Europe',ER:'Africa',ET:'Africa',
|
||||
FI:'Europe',FR:'Europe',DE:'Europe',GE:'Asia',GH:'Africa',GN:'Africa',GR:'Europe',GT:'North America',
|
||||
HN:'North America',HT:'North America',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',IR:'Asia',IQ:'Asia',
|
||||
IE:'Europe',IL:'Asia',IT:'Europe',JM:'North America',JO:'Asia',JP:'Asia',KE:'Africa',KG:'Asia',KH:'Asia',
|
||||
KR:'Asia',KW:'Asia',KZ:'Asia',LA:'Asia',LB:'Asia',LK:'Asia',LV:'Europe',LT:'Europe',LU:'Europe',LY:'Africa',
|
||||
MA:'Africa',MD:'Europe',ME:'Europe',MG:'Africa',MK:'Europe',ML:'Africa',MM:'Asia',MN:'Asia',MR:'Africa',
|
||||
MT:'Europe',MU:'Africa',MV:'Asia',MW:'Africa',MY:'Asia',MX:'North America',MZ:'Africa',
|
||||
NA:'Africa',NE:'Africa',NI:'North America',NL:'Europe',NP:'Asia',NZ:'Oceania',NO:'Europe',OM:'Asia',
|
||||
PA:'North America',PG:'Oceania',PK:'Asia',PE:'South America',PH:'Asia',PL:'Europe',PS:'Asia',
|
||||
PT:'Europe',PY:'South America',QA:'Asia',RO:'Europe',RU:'Europe',RW:'Africa',SA:'Asia',SC:'Africa',
|
||||
SD:'Africa',SG:'Asia',SI:'Europe',SK:'Europe',SN:'Africa',SO:'Africa',RS:'Europe',SV:'North America',
|
||||
SY:'Asia',TG:'Africa',TJ:'Asia',TM:'Asia',TN:'Africa',TT:'North America',TW:'Asia',TZ:'Africa',
|
||||
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
|
||||
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
|
||||
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
|
||||
};
|
||||
|
||||
// ── Geocoding helpers ───────────────────────────────────────────────────────
|
||||
|
||||
let lastNominatimCall = 0;
|
||||
|
||||
// Shared throttle: enforces ≥1.1s between any Nominatim request, across all callers.
|
||||
async function throttleNominatim() {
|
||||
const elapsed = Date.now() - lastNominatimCall;
|
||||
if (elapsed < 1100) await new Promise(r => setTimeout(r, 1100 - elapsed));
|
||||
lastNominatimCall = Date.now();
|
||||
}
|
||||
|
||||
export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
|
||||
await throttleNominatim();
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, {
|
||||
headers: { 'User-Agent': 'TREK Travel Planner' },
|
||||
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: { country_code?: string } };
|
||||
@@ -164,15 +226,15 @@ export function getCountryFromAddress(address: string | null): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Resolve a place to a country code (address -> geocode -> bbox) ──────────
|
||||
// ── Resolve a place to a country code (address -> bbox -> geocode) ──────────
|
||||
|
||||
async function resolveCountryCode(place: Place): Promise<string | null> {
|
||||
let code = getCountryFromAddress(place.address);
|
||||
if (!code && place.lat && place.lng) {
|
||||
code = await reverseGeocodeCountry(place.lat, place.lng);
|
||||
code = getCountryFromCoords(place.lat, place.lng);
|
||||
}
|
||||
if (!code && place.lat && place.lng) {
|
||||
code = getCountryFromCoords(place.lat, place.lng);
|
||||
code = await reverseGeocodeCountry(place.lat, place.lng);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
@@ -402,15 +464,22 @@ export function unmarkRegionVisited(userId: number, regionCode: string): void {
|
||||
|
||||
interface RegionInfo { country_code: string; region_code: string; region_name: string }
|
||||
|
||||
// Tracks place IDs currently being geocoded in the background to prevent duplicate enqueuing.
|
||||
const geocodingInFlight = new Set<number>();
|
||||
|
||||
const regionCache = new Map<string, RegionInfo | null>();
|
||||
|
||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
await throttleNominatim();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
{
|
||||
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: Record<string, string> };
|
||||
@@ -447,20 +516,27 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco
|
||||
: [];
|
||||
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
|
||||
|
||||
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
|
||||
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
|
||||
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||
|
||||
for (const place of uncached) {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) {
|
||||
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
cachedMap.set(place.id, { place_id: place.id, ...info });
|
||||
}
|
||||
// Nominatim rate limit: 1 req/sec
|
||||
if (uncached.indexOf(place) < uncached.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 1100));
|
||||
}
|
||||
// Kick off background geocoding for uncached places; return cached data immediately.
|
||||
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id));
|
||||
if (uncached.length > 0) {
|
||||
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||
for (const p of uncached) geocodingInFlight.add(p.id);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const place of uncached) {
|
||||
try {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
} catch {
|
||||
// individual failure — continue with remaining places
|
||||
} finally {
|
||||
geocodingInFlight.delete(place.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const p of uncached) geocodingInFlight.delete(p.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Group by country → regions with place counts
|
||||
|
||||
@@ -31,6 +31,7 @@ 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_channels', 'admin_webhook_url',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
];
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
@@ -107,16 +108,51 @@ export function avatarUrl(user: { avatar?: string | null }): string | null {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
export function isOidcOnlyMode(): boolean {
|
||||
export function resolveAuthToggles(): {
|
||||
password_login: boolean;
|
||||
password_registration: boolean;
|
||||
oidc_login: boolean;
|
||||
oidc_registration: boolean;
|
||||
} {
|
||||
const get = (key: string) =>
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
|
||||
const enabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
|
||||
if (!enabled) return false;
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
|
||||
|
||||
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
|
||||
.some(k => get(k) !== null);
|
||||
|
||||
if (hasNewKeys) {
|
||||
const result = {
|
||||
password_login: get('password_login') !== 'false',
|
||||
password_registration: get('password_registration') !== 'false',
|
||||
oidc_login: get('oidc_login') !== 'false',
|
||||
oidc_registration: get('oidc_registration') !== 'false',
|
||||
};
|
||||
if (process.env.OIDC_ONLY === 'true') {
|
||||
result.password_login = false;
|
||||
result.password_registration = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy fallback
|
||||
const oidcOnlyEnabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
|
||||
const oidcConfigured = !!(
|
||||
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
|
||||
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
|
||||
);
|
||||
return oidcConfigured;
|
||||
const oidcOnly = oidcOnlyEnabled && oidcConfigured;
|
||||
const allowReg = (get('allow_registration') ?? 'true') === 'true';
|
||||
|
||||
return {
|
||||
password_login: !oidcOnly,
|
||||
password_registration: !oidcOnly && allowReg,
|
||||
oidc_login: true,
|
||||
oidc_registration: allowReg,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOidcOnlyMode(): boolean {
|
||||
return !resolveAuthToggles().password_login;
|
||||
}
|
||||
|
||||
export function generateToken(user: { id: number | bigint }) {
|
||||
@@ -174,10 +210,9 @@ export function getPendingMfaSecret(userId: number): string | null {
|
||||
|
||||
export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
|
||||
const isDemo = process.env.DEMO_MODE === 'true';
|
||||
const { version } = require('../../package.json');
|
||||
const toggles = resolveAuthToggles();
|
||||
const version: string = process.env.APP_VERSION ?? require('../../package.json').version;
|
||||
const hasGoogleKey = !!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();
|
||||
const oidcDisplayName = process.env.OIDC_DISPLAY_NAME ||
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
|
||||
@@ -185,9 +220,6 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
|
||||
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
|
||||
);
|
||||
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;
|
||||
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
||||
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
|
||||
@@ -200,14 +232,22 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
return {
|
||||
allow_registration: isDemo ? false : allowRegistration,
|
||||
// Legacy fields (backward compat)
|
||||
allow_registration: isDemo ? false : (toggles.password_registration || toggles.oidc_registration),
|
||||
oidc_only_mode: !toggles.password_login && !toggles.password_registration,
|
||||
// Granular toggles
|
||||
password_login: toggles.password_login,
|
||||
password_registration: isDemo ? false : toggles.password_registration,
|
||||
oidc_login: toggles.oidc_login,
|
||||
oidc_registration: isDemo ? false : toggles.oidc_registration,
|
||||
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
|
||||
has_users: userCount > 0,
|
||||
setup_complete: setupComplete,
|
||||
version,
|
||||
is_prerelease: version.includes('-pre.'),
|
||||
has_maps_key: hasGoogleKey,
|
||||
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,
|
||||
@@ -265,12 +305,9 @@ export function registerUser(body: {
|
||||
}
|
||||
|
||||
if (userCount > 0 && !validInvite) {
|
||||
if (isOidcOnlyMode()) {
|
||||
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
|
||||
}
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||
if (setting?.value === 'false') {
|
||||
return { error: 'Registration is disabled. Contact your administrator.', status: 403 };
|
||||
const toggles = resolveAuthToggles();
|
||||
if (!toggles.password_registration) {
|
||||
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,6 +744,20 @@ export function updateAppSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// Lockout prevention: can't disable all login methods
|
||||
if (body.password_login !== undefined || body.oidc_login !== undefined) {
|
||||
const current = resolveAuthToggles();
|
||||
const oidcConfigured = !!(
|
||||
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
|
||||
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
|
||||
);
|
||||
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
|
||||
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
|
||||
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
|
||||
return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
if (body[key] !== undefined) {
|
||||
let val = String(body[key]);
|
||||
|
||||
@@ -117,7 +117,7 @@ export function listBackups(): BackupInfo[] {
|
||||
filename,
|
||||
size: stat.size,
|
||||
sizeText: formatSize(stat.size),
|
||||
created_at: stat.birthtime.toISOString(),
|
||||
created_at: stat.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
|
||||
|
||||
function ts(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface
|
||||
const JP_SELECT = `
|
||||
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||
`;
|
||||
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
|
||||
|
||||
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
|
||||
const contributors = db.prepare(
|
||||
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
|
||||
).all(journeyId) as { user_id: number }[];
|
||||
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number } | undefined;
|
||||
|
||||
const userIds = new Set(contributors.map(c => c.user_id));
|
||||
if (owner) userIds.add(owner.user_id);
|
||||
|
||||
for (const uid of userIds) {
|
||||
broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Access control ───────────────────────────────────────────────────────
|
||||
|
||||
export function canAccessJourney(journeyId: number, userId: number): Journey | null {
|
||||
const own = db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as Journey | undefined;
|
||||
if (own) return own;
|
||||
const contrib = db.prepare(
|
||||
'SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId);
|
||||
if (contrib) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isOwner(journeyId: number, userId: number): boolean {
|
||||
return !!db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId);
|
||||
}
|
||||
|
||||
export function canEdit(journeyId: number, userId: number): boolean {
|
||||
if (isOwner(journeyId, userId)) return true;
|
||||
const c = db.prepare(
|
||||
"SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?"
|
||||
).get(journeyId, userId) as { role: string } | undefined;
|
||||
return c?.role === 'editor' || c?.role === 'owner';
|
||||
}
|
||||
|
||||
// ── Journey CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
export function listJourneys(userId: number) {
|
||||
return db.prepare(`
|
||||
SELECT DISTINCT j.*,
|
||||
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
||||
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
|
||||
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
|
||||
FROM journeys j
|
||||
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
|
||||
WHERE j.user_id = ? OR jc.user_id = ?
|
||||
ORDER BY j.updated_at DESC
|
||||
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
|
||||
}
|
||||
|
||||
export function createJourney(userId: number, data: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
trip_ids?: number[];
|
||||
}): Journey {
|
||||
const now = ts();
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'active', ?, ?)
|
||||
`).run(userId, data.title, data.subtitle || null, now, now);
|
||||
|
||||
const journeyId = Number(res.lastInsertRowid);
|
||||
|
||||
// add owner as contributor
|
||||
db.prepare(
|
||||
'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
|
||||
).run(journeyId, userId, 'owner', now);
|
||||
|
||||
// link trips and sync skeleton entries
|
||||
if (data.trip_ids?.length) {
|
||||
for (const tripId of data.trip_ids) {
|
||||
addTripToJourney(journeyId, tripId, userId);
|
||||
}
|
||||
|
||||
// inherit cover image from first selected trip
|
||||
const firstTrip = db.prepare('SELECT cover_image FROM trips WHERE id = ?').get(data.trip_ids[0]) as { cover_image: string | null } | undefined;
|
||||
if (firstTrip?.cover_image) {
|
||||
// trip stores full path (/uploads/covers/x.jpg), journey stores relative (covers/x.jpg)
|
||||
const relativePath = firstTrip.cover_image.replace(/^\/uploads\//, '');
|
||||
db.prepare('UPDATE journeys SET cover_image = ? WHERE id = ?').run(relativePath, journeyId);
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
|
||||
}
|
||||
|
||||
export function getJourneyFull(journeyId: number, userId: number) {
|
||||
const journey = canAccessJourney(journeyId, userId);
|
||||
if (!journey) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
// group photos by entry
|
||||
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
||||
for (const p of photos) {
|
||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||
}
|
||||
|
||||
const enrichedEntries = entries
|
||||
.filter(e => {
|
||||
// hide empty Gallery entries (no photos, no story)
|
||||
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
|
||||
return true;
|
||||
})
|
||||
.map(e => ({
|
||||
...e,
|
||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
|
||||
photos: photosByEntry[e.id] || [],
|
||||
source_trip_name: e.source_trip_id
|
||||
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
|
||||
: null,
|
||||
}));
|
||||
|
||||
// linked trips
|
||||
const trips = db.prepare(`
|
||||
SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency,
|
||||
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
|
||||
FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id
|
||||
WHERE jt.journey_id = ? ORDER BY t.start_date ASC
|
||||
`).all(journeyId);
|
||||
|
||||
// contributors
|
||||
const contributorsRaw = db.prepare(`
|
||||
SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar
|
||||
FROM journey_contributors jc JOIN users u ON jc.user_id = u.id
|
||||
WHERE jc.journey_id = ? ORDER BY jc.added_at
|
||||
`).all(journeyId) as any[];
|
||||
const contributors = contributorsRaw.map(c => ({
|
||||
...c,
|
||||
avatar_url: c.avatar ? `/uploads/avatars/${c.avatar}` : null,
|
||||
}));
|
||||
|
||||
// stats
|
||||
const entryCount = entries.filter(e => e.type === 'entry').length;
|
||||
const photoCount = photos.length;
|
||||
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||
|
||||
const userPrefs = db.prepare(
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
|
||||
|
||||
return {
|
||||
...journey,
|
||||
entries: enrichedEntries,
|
||||
trips,
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
|
||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cover_gradient: string;
|
||||
cover_image: string;
|
||||
status: string;
|
||||
}>): Journey | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
|
||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (val !== undefined && allowed.includes(key)) {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(ts());
|
||||
values.push(journeyId);
|
||||
db.prepare(`UPDATE journeys SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
|
||||
}
|
||||
|
||||
export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) {
|
||||
if (!canAccessJourney(journeyId, userId)) return null;
|
||||
if (data.hide_skeletons !== undefined) {
|
||||
db.prepare(
|
||||
'UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?'
|
||||
).run(data.hide_skeletons ? 1 : 0, journeyId, userId);
|
||||
}
|
||||
const row = db.prepare(
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { hide_skeletons: number };
|
||||
return { hide_skeletons: !!row.hide_skeletons };
|
||||
}
|
||||
|
||||
export function deleteJourney(journeyId: number, userId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Trip management ──────────────────────────────────────────────────────
|
||||
|
||||
export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean {
|
||||
const now = ts();
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)'
|
||||
).run(journeyId, tripId, now);
|
||||
} catch { return false; }
|
||||
|
||||
// sync skeleton entries for all places in this trip
|
||||
syncTripPlaces(journeyId, tripId, userId);
|
||||
// import existing trip photos (Immich/Synology) with sharing settings
|
||||
syncTripPhotos(journeyId, tripId);
|
||||
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
|
||||
// remove skeleton entries that haven't been filled in
|
||||
db.prepare(`
|
||||
DELETE FROM journey_entries
|
||||
WHERE journey_id = ? AND source_trip_id = ? AND type = 'skeleton'
|
||||
`).run(journeyId, tripId);
|
||||
|
||||
// detach filled entries from this trip
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
|
||||
WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton'
|
||||
`).run(journeyId, tripId);
|
||||
|
||||
db.prepare('DELETE FROM journey_trips WHERE journey_id = ? AND trip_id = ?').run(journeyId, tripId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Sync engine ──────────────────────────────────────────────────────────
|
||||
|
||||
export function syncTripPlaces(journeyId: number, tripId: number, authorId: number) {
|
||||
const places = db.prepare(`
|
||||
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
|
||||
FROM places p
|
||||
INNER JOIN day_assignments da ON da.place_id = p.id
|
||||
INNER JOIN days d ON da.day_id = d.id
|
||||
WHERE p.trip_id = ?
|
||||
ORDER BY d.day_number ASC, da.order_index ASC
|
||||
`).all(tripId) as any[];
|
||||
|
||||
const now = ts();
|
||||
const existing = db.prepare(
|
||||
'SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?'
|
||||
).all(journeyId, tripId) as { source_place_id: number }[];
|
||||
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
||||
|
||||
for (const place of places) {
|
||||
if (existingPlaceIds.has(place.id)) continue;
|
||||
existingPlaceIds.add(place.id);
|
||||
|
||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||
const entryTime = place.assignment_time || place.place_time || null;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
journeyId, tripId, place.id, authorId,
|
||||
place.name, entryDate, entryTime,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
place.day_number || 0, now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// import trip_photos into journey when a trip is linked
|
||||
function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
const tripPhotos = db.prepare(
|
||||
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
|
||||
).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
|
||||
if (!tripPhotos.length) return;
|
||||
|
||||
const now = ts();
|
||||
|
||||
// find or create a "Photos" entry for this trip's photos
|
||||
let photoEntry = db.prepare(`
|
||||
SELECT id FROM journey_entries
|
||||
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
|
||||
`).get(journeyId, tripId) as { id: number } | undefined;
|
||||
|
||||
if (!photoEntry) {
|
||||
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
|
||||
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
|
||||
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
|
||||
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
|
||||
photoEntry = { id: Number(res.lastInsertRowid) };
|
||||
}
|
||||
|
||||
// import each trip photo, skip duplicates (by photo_id)
|
||||
for (const tp of tripPhotos) {
|
||||
const exists = db.prepare(
|
||||
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
|
||||
).get(photoEntry.id, tp.photo_id);
|
||||
if (exists) continue;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
|
||||
}
|
||||
}
|
||||
|
||||
// called when a trip place is created
|
||||
export function onPlaceCreated(tripId: number, placeId: number) {
|
||||
const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { journey_id: number }[];
|
||||
if (!links.length) return;
|
||||
|
||||
const place = db.prepare(`
|
||||
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
|
||||
FROM places p
|
||||
INNER JOIN day_assignments da ON da.place_id = p.id
|
||||
INNER JOIN days d ON da.day_id = d.id
|
||||
WHERE p.id = ?
|
||||
`).get(placeId) as any;
|
||||
if (!place) return; // not assigned to a day yet — skip
|
||||
|
||||
const now = ts();
|
||||
for (const link of links) {
|
||||
const already = db.prepare(
|
||||
'SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
|
||||
).get(link.journey_id, placeId);
|
||||
if (already) continue;
|
||||
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
||||
const entryDate = place.day_date;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`).run(
|
||||
link.journey_id, tripId, placeId, journey.user_id,
|
||||
place.name, entryDate, place.assignment_time || place.place_time || null,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// called when a trip place is updated
|
||||
export function onPlaceUpdated(placeId: number) {
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE source_place_id = ?'
|
||||
).all(placeId) as JourneyEntry[];
|
||||
if (!entries.length) return;
|
||||
|
||||
const place = db.prepare(`
|
||||
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
|
||||
FROM places p
|
||||
LEFT JOIN day_assignments da ON da.place_id = p.id
|
||||
LEFT JOIN days d ON da.day_id = d.id
|
||||
WHERE p.id = ?
|
||||
`).get(placeId) as any;
|
||||
if (!place) return;
|
||||
|
||||
const now = ts();
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'skeleton') {
|
||||
// update everything on skeletons
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
place.name,
|
||||
place.day_date || entry.entry_date,
|
||||
place.assignment_time || place.place_time || entry.entry_time,
|
||||
place.address || place.name,
|
||||
place.lat || null, place.lng || null,
|
||||
now, entry.id
|
||||
);
|
||||
} else {
|
||||
// for filled entries, only update location silently
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(place.address || place.name, place.lat || null, place.lng || null, now, entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// called when a trip place is deleted
|
||||
export function onPlaceDeleted(placeId: number) {
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE source_place_id = ?'
|
||||
).all(placeId) as JourneyEntry[];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'skeleton') {
|
||||
// no content: just delete
|
||||
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
|
||||
if (!hasPhotos && !entry.story) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// entry has content: keep it, detach, add note
|
||||
const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
|
||||
const newStory = (entry.story || '') + note;
|
||||
db.prepare(
|
||||
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?'
|
||||
).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, ts(), entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entries ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function listEntries(journeyId: number, userId: number) {
|
||||
if (!canAccessJourney(journeyId, userId)) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
||||
for (const p of photos) {
|
||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||
}
|
||||
|
||||
return entries.map(e => ({
|
||||
...e,
|
||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
|
||||
photos: photosByEntry[e.id] || [],
|
||||
source_trip_name: e.source_trip_id
|
||||
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createEntry(journeyId: number, userId: number, data: {
|
||||
type?: string;
|
||||
title?: string;
|
||||
story?: string;
|
||||
entry_date: string;
|
||||
entry_time?: string;
|
||||
location_name?: string;
|
||||
location_lat?: number;
|
||||
location_lng?: number;
|
||||
mood?: string;
|
||||
weather?: string;
|
||||
tags?: string[];
|
||||
pros_cons?: { pros: string[]; cons: string[] };
|
||||
visibility?: string;
|
||||
}, sid?: string): JourneyEntry | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
|
||||
const now = ts();
|
||||
const maxOrder = db.prepare(
|
||||
'SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
|
||||
).get(journeyId, data.entry_date) as { m: number | null };
|
||||
|
||||
const prosConsJson = data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length)
|
||||
? JSON.stringify(data.pros_cons) : null;
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
journeyId, userId,
|
||||
data.type || 'entry',
|
||||
data.title || null,
|
||||
data.story || null,
|
||||
data.entry_date,
|
||||
data.entry_time || null,
|
||||
data.location_name || null,
|
||||
data.location_lat ?? null,
|
||||
data.location_lng ?? null,
|
||||
data.mood || null,
|
||||
data.weather || null,
|
||||
data.tags?.length ? JSON.stringify(data.tags) : null,
|
||||
prosConsJson,
|
||||
data.visibility || 'private',
|
||||
(maxOrder?.m ?? -1) + 1,
|
||||
now, now
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
|
||||
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateEntry(entryId: number, userId: number, data: Partial<{
|
||||
type: string;
|
||||
title: string;
|
||||
story: string;
|
||||
entry_date: string;
|
||||
entry_time: string;
|
||||
location_name: string;
|
||||
location_lat: number;
|
||||
location_lng: number;
|
||||
mood: string;
|
||||
weather: string;
|
||||
tags: string[];
|
||||
pros_cons: { pros: string[]; cons: string[] };
|
||||
visibility: string;
|
||||
sort_order: number;
|
||||
}>, sid?: string): JourneyEntry | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (val === undefined) continue;
|
||||
if (key === 'tags') {
|
||||
fields.push('tags = ?');
|
||||
values.push(Array.isArray(val) ? JSON.stringify(val) : val);
|
||||
} else if (key === 'pros_cons') {
|
||||
fields.push('pros_cons = ?');
|
||||
values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
|
||||
} else {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
// if adding story to a skeleton, promote to entry
|
||||
if (entry.type === 'skeleton' && data.story && data.story.trim()) {
|
||||
fields.push('type = ?');
|
||||
values.push('entry');
|
||||
}
|
||||
|
||||
if (fields.length === 0) return entry;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(ts());
|
||||
values.push(entryId);
|
||||
db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
// touch the journey
|
||||
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteEntry(entryId: number, userId: number, sid?: string): boolean {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return false;
|
||||
if (!canEdit(entry.journey_id, userId)) return false;
|
||||
|
||||
// delete photos along with the entry — no more orphan Gallery entries
|
||||
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
|
||||
|
||||
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
|
||||
// Revert filled entry back to skeleton instead of deleting
|
||||
db.prepare(`
|
||||
UPDATE journey_entries
|
||||
SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL,
|
||||
visibility = 'private', updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(ts(), entryId);
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid);
|
||||
} else {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
|
||||
}
|
||||
|
||||
// clean up any empty Gallery entries in this journey
|
||||
db.prepare(`
|
||||
DELETE FROM journey_entries WHERE journey_id = ? AND title = 'Gallery'
|
||||
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
|
||||
`).run(entry.journey_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Photos ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
|
||||
// skip if already added
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
||||
if (exists) return null;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined;
|
||||
if (!source) return null;
|
||||
|
||||
if (source.entry_id === entryId) return source;
|
||||
|
||||
const oldEntryId = source.entry_id;
|
||||
|
||||
// move photo to the target entry
|
||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
||||
|
||||
// clean up: if old entry was a "Gallery" entry and is now empty, delete it
|
||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
|
||||
if (oldEntry && oldEntry.title === 'Gallery') {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
|
||||
if (remaining.c === 0) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||
// Get the trek_photo_id from the journey_photo, then update the central registry
|
||||
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
|
||||
if (!jp) return;
|
||||
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
|
||||
}
|
||||
|
||||
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
if (!photo) return null;
|
||||
if (!canEdit(photo.journey_id, userId)) return null;
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
|
||||
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
|
||||
if (!fields.length) return photo;
|
||||
|
||||
values.push(photoId);
|
||||
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
if (!photo) return null;
|
||||
if (!canEdit(photo.journey_id, userId)) return null;
|
||||
|
||||
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
||||
|
||||
// clean up empty Gallery entries left behind
|
||||
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
|
||||
if (!remaining) {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(photo.entry_id) as JourneyEntry | undefined;
|
||||
if (entry && entry.title === 'Gallery' && !entry.story) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(photo.entry_id);
|
||||
}
|
||||
}
|
||||
|
||||
return photo;
|
||||
}
|
||||
|
||||
// ── Contributors ─────────────────────────────────────────────────────────
|
||||
|
||||
export function addContributor(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
if (targetUserId === userId) return false;
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
|
||||
).run(journeyId, targetUserId, role, ts());
|
||||
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function updateContributorRole(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare(
|
||||
'UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?'
|
||||
).run(role, journeyId, targetUserId);
|
||||
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeContributor(journeyId: number, userId: number, targetUserId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare(
|
||||
"DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'"
|
||||
).run(journeyId, targetUserId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Suggestions ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getSuggestions(userId: number) {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
return db.prepare(`
|
||||
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
|
||||
(SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count
|
||||
FROM trips t
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?)
|
||||
AND t.end_date IS NOT NULL
|
||||
AND t.end_date >= ?
|
||||
AND t.end_date <= date('now')
|
||||
AND t.id NOT IN (SELECT trip_id FROM journey_trips)
|
||||
ORDER BY t.end_date DESC
|
||||
`).all(userId, userId, userId, thirtyDaysAgo);
|
||||
}
|
||||
|
||||
// ── User trips (for trip picker) ─────────────────────────────────────────
|
||||
|
||||
export function listUserTrips(userId: number) {
|
||||
return db.prepare(`
|
||||
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
|
||||
(SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count
|
||||
FROM trips t
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
|
||||
WHERE t.user_id = ? OR tm.user_id = ?
|
||||
ORDER BY t.start_date DESC
|
||||
`).all(userId, userId, userId);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { db } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface JourneySharePermissions {
|
||||
share_timeline?: boolean;
|
||||
share_gallery?: boolean;
|
||||
share_map?: boolean;
|
||||
}
|
||||
|
||||
interface JourneyShareTokenInfo {
|
||||
token: string;
|
||||
created_at: string;
|
||||
share_timeline: boolean;
|
||||
share_gallery: boolean;
|
||||
share_map: boolean;
|
||||
}
|
||||
|
||||
export function createOrUpdateJourneyShareLink(
|
||||
journeyId: number,
|
||||
createdBy: number,
|
||||
permissions: JourneySharePermissions
|
||||
): { token: string; created: boolean } {
|
||||
const {
|
||||
share_timeline = true,
|
||||
share_gallery = true,
|
||||
share_map = true,
|
||||
} = permissions;
|
||||
|
||||
const existing = db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
|
||||
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
|
||||
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
|
||||
if (!row) return null;
|
||||
return {
|
||||
token: row.token,
|
||||
created_at: row.created_at,
|
||||
share_timeline: !!row.share_timeline,
|
||||
share_gallery: !!row.share_gallery,
|
||||
share_map: !!row.share_map,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteJourneyShareLink(journeyId: number): void {
|
||||
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
|
||||
}
|
||||
|
||||
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.photo_id, tkp.owner_id, je.journey_id
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ? AND je.journey_id = ?
|
||||
`).get(photoId, row.journey_id) as any;
|
||||
if (!photo) return null;
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
|
||||
}
|
||||
|
||||
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
const photo = db.prepare(`
|
||||
SELECT tkp.owner_id FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE tkp.asset_id = ? AND je.journey_id = ?
|
||||
`).get(assetId, row.journey_id) as any;
|
||||
if (!photo) {
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { ownerId: journey.user_id } : null;
|
||||
}
|
||||
return { ownerId: photo.owner_id };
|
||||
}
|
||||
|
||||
export function getPublicJourney(token: string) {
|
||||
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
|
||||
const journey = db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
if (!journey) return null;
|
||||
|
||||
// Entries with photos
|
||||
const entries = db.prepare(`
|
||||
SELECT je.* FROM journey_entries je
|
||||
WHERE je.journey_id = ? AND je.type != 'skeleton'
|
||||
ORDER BY je.entry_date, je.sort_order
|
||||
`).all(row.journey_id) as any[];
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE je.journey_id = ?
|
||||
ORDER BY jp.sort_order
|
||||
`).all(row.journey_id) as any[];
|
||||
|
||||
const photosByEntry: Record<number, any[]> = {};
|
||||
for (const p of photos) {
|
||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||
}
|
||||
|
||||
const enrichedEntries = entries
|
||||
.filter(e => {
|
||||
// hide empty Gallery entries (no photos, no story)
|
||||
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
|
||||
return true;
|
||||
})
|
||||
.map(e => ({
|
||||
...e,
|
||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
|
||||
photos: photosByEntry[e.id] || [],
|
||||
}));
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
entries: entries.length,
|
||||
photos: photos.length,
|
||||
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
};
|
||||
|
||||
return {
|
||||
journey: {
|
||||
title: journey.title,
|
||||
subtitle: journey.subtitle,
|
||||
cover_image: journey.cover_image,
|
||||
status: journey.status,
|
||||
},
|
||||
entries: enrichedEntries,
|
||||
stats,
|
||||
permissions: {
|
||||
share_timeline: !!row.share_timeline,
|
||||
share_gallery: !!row.share_gallery,
|
||||
share_map: !!row.share_map,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { TextDecoder } from 'util';
|
||||
|
||||
export interface ParsedKmlPlacemark {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
folderName: string | null;
|
||||
}
|
||||
|
||||
export interface KmlPlacemarkNode {
|
||||
placemark: any;
|
||||
folderName: string | null;
|
||||
}
|
||||
|
||||
export interface KmlImportSummary {
|
||||
totalPlacemarks: number;
|
||||
createdCount: number;
|
||||
skippedCount: number;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const UTF8_DECODER_FATAL = new TextDecoder('utf-8', { fatal: true });
|
||||
const UTF8_DECODER_LOOSE = new TextDecoder('utf-8');
|
||||
|
||||
const ENTITY_MAP: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
};
|
||||
|
||||
function asArray<T>(value: T | T[] | null | undefined): T[] {
|
||||
if (value == null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
// Parsed objects (mixed-content XML parsed without stopNodes) must not
|
||||
// produce "[object Object]" — extract #text if present, else return null.
|
||||
if (typeof value === 'object') {
|
||||
const candidate = (value as Record<string, unknown>)['#text'];
|
||||
if (typeof candidate === 'string') return candidate.trim() || null;
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
const withNamedEntities = value.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITY_MAP[m] || m);
|
||||
|
||||
return withNamedEntities
|
||||
.replace(/&#(\d+);/g, (_, dec) => {
|
||||
const code = Number(dec);
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
|
||||
})
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||
const code = Number.parseInt(hex, 16);
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeUtf8WithWarning(fileBuffer: Buffer): { text: string; warning: string | null } {
|
||||
try {
|
||||
return { text: UTF8_DECODER_FATAL.decode(fileBuffer), warning: null };
|
||||
} catch {
|
||||
return {
|
||||
text: UTF8_DECODER_LOOSE.decode(fileBuffer),
|
||||
warning: 'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
const raw = asTrimmedString(value);
|
||||
if (!raw) return null;
|
||||
|
||||
// Unwrap CDATA sections — present when fast-xml-parser returns raw node text
|
||||
// via stopNodes. Must happen before tag-stripping so the CDATA markers are
|
||||
// not mis-parsed by the <[^>]+> regex.
|
||||
const withoutCdata = raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
|
||||
|
||||
const withLineBreaks = withoutCdata.replace(/<br\s*\/?>/gi, '\n');
|
||||
const stripped = withLineBreaks.replace(/<[^>]+>/g, '');
|
||||
const decoded = decodeHtmlEntities(stripped)
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[\t\f\v]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return decoded || null;
|
||||
}
|
||||
|
||||
export function parseKmlPointCoordinates(value: unknown): { lat: number; lng: number } | null {
|
||||
const coordinates = asTrimmedString(value);
|
||||
if (!coordinates) return null;
|
||||
|
||||
const firstCoordinate = coordinates.split(/\s+/)[0];
|
||||
const [lngRaw, latRaw] = firstCoordinate.split(',');
|
||||
if (lngRaw == null || latRaw == null) return null;
|
||||
|
||||
const lng = Number.parseFloat(lngRaw);
|
||||
const lat = Number.parseFloat(latRaw);
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
export function createKmlImportSummary(totalPlacemarks: number): KmlImportSummary {
|
||||
return {
|
||||
totalPlacemarks,
|
||||
createdCount: 0,
|
||||
skippedCount: 0,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCategoryNameLookup(categories: { id: number; name: string }[]): Map<string, number> {
|
||||
const lookup = new Map<string, number>();
|
||||
for (const category of categories) {
|
||||
const normalizedName = category.name.trim().toLowerCase();
|
||||
if (!normalizedName) continue;
|
||||
if (!lookup.has(normalizedName)) {
|
||||
lookup.set(normalizedName, category.id);
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export function resolveCategoryIdForFolder(folderName: string | null, lookup: Map<string, number>): number | null {
|
||||
if (!folderName) return null;
|
||||
const normalizedFolder = folderName.trim().toLowerCase();
|
||||
if (!normalizedFolder) return null;
|
||||
return lookup.get(normalizedFolder) ?? null;
|
||||
}
|
||||
|
||||
export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
const nodes: KmlPlacemarkNode[] = [];
|
||||
|
||||
const visitNode = (node: any, currentFolderName: string | null): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
for (const placemark of asArray(node.Placemark)) {
|
||||
nodes.push({ placemark, folderName: currentFolderName });
|
||||
}
|
||||
|
||||
for (const folder of asArray(node.Folder)) {
|
||||
// Nested folders inherit/override folder context used for category matching.
|
||||
const folderName = asTrimmedString(folder?.name) || currentFolderName;
|
||||
visitNode(folder, folderName);
|
||||
}
|
||||
|
||||
for (const childDocument of asArray(node.Document)) {
|
||||
visitNode(childDocument, currentFolderName);
|
||||
}
|
||||
};
|
||||
|
||||
visitNode(kmlRoot, null);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
|
||||
const coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
|
||||
|
||||
return {
|
||||
name: asTrimmedString(node.placemark?.name),
|
||||
description: sanitizeKmlDescription(node.placemark?.description),
|
||||
lat: coordinates?.lat ?? null,
|
||||
lng: coordinates?.lng ?? null,
|
||||
folderName: node.folderName,
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,16 @@ interface GooglePlaceResult {
|
||||
types?: string[];
|
||||
}
|
||||
|
||||
interface GoogleAutocompleteSuggestion {
|
||||
placePrediction?: {
|
||||
placeId: string;
|
||||
structuredFormat?: {
|
||||
mainText?: { text: string };
|
||||
secondaryText?: { text: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
userRatingCount?: number;
|
||||
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
||||
@@ -43,7 +53,7 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)';
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
|
||||
// ── Photo cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,7 +99,10 @@ export async function searchNominatim(query: string, lang?: string) {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
});
|
||||
if (!response.ok) throw new Error('Nominatim API error');
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`);
|
||||
}
|
||||
const data = await response.json() as NominatimResult[];
|
||||
return data.map(item => ({
|
||||
google_place_id: null,
|
||||
@@ -105,6 +118,34 @@ export async function searchNominatim(query: string, lang?: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Nominatim lookup (by OSM ID) ────────────────────────────────────────────
|
||||
|
||||
export async function lookupNominatim(osmType: string, osmId: string, lang?: string): Promise<{
|
||||
name: string; address: string; lat: number | null; lng: number | null;
|
||||
} | null> {
|
||||
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
|
||||
const params = new URLSearchParams({
|
||||
osm_ids: `${typePrefix}${osmId}`,
|
||||
format: 'json',
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as NominatimResult[];
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
return {
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: parseFloat(item.lat) || null,
|
||||
lng: parseFloat(item.lon) || null,
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Overpass API (OSM details) ───────────────────────────────────────────────
|
||||
|
||||
export async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
||||
@@ -303,6 +344,86 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'google' };
|
||||
}
|
||||
|
||||
// ── Autocomplete (Google or Nominatim fallback) ─────────────────────────────
|
||||
|
||||
export async function autocompletePlaces(
|
||||
userId: number,
|
||||
input: string,
|
||||
lang?: string,
|
||||
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
|
||||
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
||||
const apiKey = getMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
return autocompleteNominatim(input, lang);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
input,
|
||||
languageCode: lang || 'en',
|
||||
};
|
||||
if (locationBias) {
|
||||
body.locationBias = {
|
||||
rectangle: {
|
||||
low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng },
|
||||
high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json() as { suggestions?: GoogleAutocompleteSuggestion[]; error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const suggestions = (data.suggestions || [])
|
||||
.filter((s) => s.placePrediction)
|
||||
.slice(0, 5)
|
||||
.map((s) => ({
|
||||
placeId: s.placePrediction!.placeId,
|
||||
mainText: s.placePrediction!.structuredFormat?.mainText?.text || '',
|
||||
secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '',
|
||||
}));
|
||||
|
||||
return { suggestions, source: 'google' };
|
||||
}
|
||||
|
||||
async function autocompleteNominatim(
|
||||
input: string,
|
||||
lang?: string,
|
||||
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
||||
try {
|
||||
const places = await searchNominatim(input, lang);
|
||||
const suggestions = places
|
||||
.filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '')
|
||||
.slice(0, 5)
|
||||
.map((p) => {
|
||||
const parts = (p.address || '').split(',').map((s) => s.trim());
|
||||
return {
|
||||
placeId: p.osm_id,
|
||||
mainText: p.name || parts[0] || '',
|
||||
secondaryText: parts.slice(1).join(', '),
|
||||
};
|
||||
});
|
||||
return { suggestions, source: 'nominatim' };
|
||||
} catch (err) {
|
||||
console.error('Nominatim autocomplete failed:', err);
|
||||
return { suggestions: [], source: 'nominatim' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Place details (Google or OSM) ────────────────────────────────────────────
|
||||
|
||||
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
|
||||
@@ -310,8 +431,23 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
if (placeId.includes(':')) {
|
||||
const [osmType, osmId] = placeId.split(':');
|
||||
const element = await fetchOverpassDetails(osmType, osmId);
|
||||
if (!element?.tags) return { place: buildOsmDetails({}, osmType, osmId) };
|
||||
return { place: buildOsmDetails(element.tags, osmType, osmId) };
|
||||
const details = buildOsmDetails(element?.tags || {}, osmType, osmId);
|
||||
|
||||
// Fetch Nominatim only when Overpass lacks coordinates or address
|
||||
const d = details as Record<string, unknown>;
|
||||
const needsNominatim = !d.lat || !d.lng || !d.address;
|
||||
const nominatim = needsNominatim ? await lookupNominatim(osmType, osmId, lang) : null;
|
||||
|
||||
return {
|
||||
place: {
|
||||
...details,
|
||||
name: (d.name as string) || nominatim?.name || element?.tags?.name || '',
|
||||
address: (d.address as string) || nominatim?.address || '',
|
||||
lat: d.lat ?? nominatim?.lat ?? null,
|
||||
lng: d.lng ?? nominatim?.lng ?? null,
|
||||
osm_id: placeId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Google details
|
||||
|
||||
@@ -123,14 +123,40 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
if (requestingUserId === ownerUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Journey photos use tripId=0 — check journey_photos + journey_contributors
|
||||
if (tripId === '0') {
|
||||
const journeyPhoto = db.prepare(`
|
||||
SELECT jp.entry_id, je.journey_id
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON je.id = jp.entry_id
|
||||
WHERE tkp.asset_id = ?
|
||||
AND tkp.provider = ?
|
||||
AND tkp.owner_id = ?
|
||||
LIMIT 1
|
||||
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
|
||||
if (!journeyPhoto) return false;
|
||||
|
||||
const access = db.prepare(`
|
||||
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
|
||||
LIMIT 1
|
||||
`).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
|
||||
return !!access;
|
||||
}
|
||||
|
||||
// Regular trip photos — join through trek_photos
|
||||
const sharedAsset = db.prepare(`
|
||||
SELECT 1
|
||||
FROM trip_photos
|
||||
WHERE user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
AND trip_id = ?
|
||||
AND shared = 1
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.user_id = ?
|
||||
AND tkp.asset_id = ?
|
||||
AND tkp.provider = ?
|
||||
AND tp.trip_id = ?
|
||||
AND tp.shared = 1
|
||||
LIMIT 1
|
||||
`).get(ownerUserId, assetId, provider, tripId);
|
||||
|
||||
@@ -141,6 +167,52 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
}
|
||||
|
||||
|
||||
// ── Unified photo access check (trek_photos based) ──────────────────────
|
||||
|
||||
export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
|
||||
const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined;
|
||||
if (!photo) return false;
|
||||
|
||||
// Owner always has access
|
||||
if (photo.owner_id === requestingUserId) return true;
|
||||
|
||||
// Check trip_photos — is this photo shared in a trip the user has access to?
|
||||
const tripAccess = db.prepare(`
|
||||
SELECT 1 FROM trip_photos tp
|
||||
WHERE tp.photo_id = ?
|
||||
AND tp.shared = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
||||
if (tripAccess) return true;
|
||||
|
||||
// Check journey_photos — is this photo in a journey the user can access?
|
||||
const journeyAccess = db.prepare(`
|
||||
SELECT 1 FROM journey_photos jp
|
||||
JOIN journey_entries je ON je.id = jp.entry_id
|
||||
WHERE jp.photo_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ?
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
||||
if (journeyAccess) return true;
|
||||
|
||||
// Local photos without owner (uploaded files) — check if user has journey access
|
||||
if (photo.provider === 'local' && !photo.owner_id) {
|
||||
return !!journeyAccess;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------
|
||||
//helpers for album link syncing
|
||||
|
||||
|
||||
@@ -149,44 +149,36 @@ export async function browseTimeline(
|
||||
export async function searchPhotos(
|
||||
userId: number,
|
||||
from?: string,
|
||||
to?: string
|
||||
): Promise<{ assets?: any[]; error?: string; status?: number }> {
|
||||
to?: string,
|
||||
page: number = 1,
|
||||
size: number = 50,
|
||||
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
// Paginate through all results (Immich limits per-page to 1000)
|
||||
const allAssets: any[] = [];
|
||||
let page = 1;
|
||||
const pageSize = 1000;
|
||||
while (true) {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': creds.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) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
||||
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) => ({
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': creds.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,
|
||||
page,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const items = data.assets?.items || [];
|
||||
const assets = items.map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
}));
|
||||
return { assets };
|
||||
return { assets, hasMore: items.length >= size };
|
||||
} catch {
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
}
|
||||
@@ -266,18 +258,34 @@ export async function listAlbums(
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
// Fetch both owned and shared albums
|
||||
const [ownResp, sharedResp] = await Promise.all([
|
||||
safeFetch(`${creds.immich_url}/api/albums`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
}),
|
||||
safeFetch(`${creds.immich_url}/api/albums?shared=true`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
}),
|
||||
]);
|
||||
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
|
||||
const ownAlbums = await ownResp.json() as any[];
|
||||
const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : [];
|
||||
const seenIds = new Set<string>();
|
||||
const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => {
|
||||
if (seenIds.has(a.id)) return false;
|
||||
seenIds.add(a.id);
|
||||
return true;
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
|
||||
const albums = (await resp.json() as any[]).map((a: any) => ({
|
||||
const albums = allAlbums.map((a: any) => ({
|
||||
id: a.id,
|
||||
albumName: a.albumName,
|
||||
assetCount: a.assetCount || 0,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
albumThumbnailAssetId: a.albumThumbnailAssetId,
|
||||
shared: a.shared || a.sharedUsers?.length > 0,
|
||||
}));
|
||||
return { albums };
|
||||
} catch {
|
||||
@@ -285,6 +293,32 @@ export async function listAlbums(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlbumPhotos(
|
||||
userId: number,
|
||||
albumId: string,
|
||||
): Promise<{ assets?: any[]; error?: string; status?: number }> {
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE').map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
}));
|
||||
return { assets };
|
||||
} catch {
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
export function listAlbumLinks(tripId: string) {
|
||||
return db.prepare(`
|
||||
SELECT tal.*, u.username
|
||||
@@ -357,3 +391,63 @@ export async function syncAlbumAssets(
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload to Immich ──────────────────────────────────────────────────────
|
||||
|
||||
export async function uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return null;
|
||||
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
|
||||
const fullPath = path.join(__dirname, '../../../uploads', filePath);
|
||||
if (!fs.existsSync(fullPath)) return null;
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(fullPath);
|
||||
const boundary = '----ImmichUpload' + Date.now();
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
|
||||
};
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
const addField = (name: string, value: string) => {
|
||||
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
|
||||
};
|
||||
addField('deviceAssetId', `trek-${Date.now()}`);
|
||||
addField('deviceId', 'TREK');
|
||||
addField('fileCreatedAt', now);
|
||||
addField('fileModifiedAt', now);
|
||||
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`
|
||||
));
|
||||
parts.push(fileBuffer);
|
||||
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||||
|
||||
const body = Buffer.concat(parts);
|
||||
|
||||
const res = await safeFetch(`${creds.immich_url}/api/assets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': creds.immich_api_key,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(body.length),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { id?: string };
|
||||
return data.id || null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db } from '../../db/database';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
export function getOrCreateTrekPhoto(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, ownerId);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function getOrCreateLocalTrekPhoto(
|
||||
filePath: string,
|
||||
thumbnailPath?: string | null,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?"
|
||||
).get(filePath) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)'
|
||||
).run('local', filePath, thumbnailPath || null, width || null, height || null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
|
||||
return db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoId) as TrekPhoto | undefined || null;
|
||||
}
|
||||
|
||||
// ── Streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function streamPhoto(
|
||||
res: Response,
|
||||
userId: number,
|
||||
photoId: number,
|
||||
kind: 'thumbnail' | 'original',
|
||||
): Promise<void> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) {
|
||||
res.status(404).json({ error: 'Photo not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(filePath);
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
res.status(400).json({ error: `Unknown provider: ${photo.provider}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Asset Info ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPhotoInfo(
|
||||
userId: number,
|
||||
photoId: number,
|
||||
): Promise<ServiceResult<AssetInfo>> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) return fail('Photo not found', 404);
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
return success({
|
||||
id: String(photo.id),
|
||||
takenAt: photo.created_at,
|
||||
city: null,
|
||||
country: null,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
fileName: photo.file_path?.split('/').pop() || null,
|
||||
} as AssetInfo);
|
||||
}
|
||||
case 'immich': {
|
||||
const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
if (result.error) return fail(result.error, result.status || 500);
|
||||
return success(result.data as AssetInfo);
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
}
|
||||
default:
|
||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update provider on existing trek_photo (for Immich upload sync) ─────
|
||||
|
||||
export function setTrekPhotoProvider(
|
||||
trekPhotoId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): void {
|
||||
db.prepare(
|
||||
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?'
|
||||
).run(provider, assetId, ownerId, trekPhotoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo || photo.provider !== 'local' || !photo.file_path) return null;
|
||||
return path.join(__dirname, '../../../uploads', photo.file_path);
|
||||
}
|
||||
@@ -19,26 +19,62 @@ import {
|
||||
SyncAlbumResult,
|
||||
AssetInfo
|
||||
} from './helpersService';
|
||||
import { send as sendNotification } from '../notificationService';
|
||||
|
||||
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
||||
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
|
||||
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
|
||||
// The API endpoint is always at {base_url}/webapi/entry.cgi.
|
||||
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
|
||||
|
||||
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
|
||||
101: 'Missing API, method, or version parameter.',
|
||||
102: 'Requested API does not exist.',
|
||||
103: 'Requested method does not exist.',
|
||||
104: 'Requested API version is not supported.',
|
||||
105: 'Insufficient privilege.',
|
||||
106: 'Connection timeout.',
|
||||
107: 'Multiple logins blocked from this IP.',
|
||||
117: 'Manager privilege required.',
|
||||
119: 'Session is invalid or expired.',
|
||||
400: 'Invalid credentials.',
|
||||
401: 'Session expired or account disabled.',
|
||||
402: 'No permission to use this account.',
|
||||
403: 'Two-factor authentication code required.',
|
||||
404: 'Two-factor authentication failed.',
|
||||
406: 'Two-factor authentication is enforced for this account.',
|
||||
407: 'Maximum login attempts reached.',
|
||||
408: 'Password expired.',
|
||||
409: 'Remote password expired.',
|
||||
410: 'Password must be changed before login.',
|
||||
412: 'Guest account cannot log in.',
|
||||
413: 'OTP system files are corrupted.',
|
||||
414: 'Unable to log in.',
|
||||
416: 'Unable to log in.',
|
||||
417: 'OTP system is full.',
|
||||
498: 'System is upgrading.',
|
||||
499: 'System is not ready.',
|
||||
};
|
||||
|
||||
interface SynologyUserRecord {
|
||||
synology_url?: string | null;
|
||||
synology_username?: string | null;
|
||||
synology_password?: string | null;
|
||||
synology_sid?: string | null;
|
||||
synology_did?: string | null;
|
||||
synology_skip_ssl?: number | null;
|
||||
};
|
||||
|
||||
interface SynologyCredentials {
|
||||
synology_url: string;
|
||||
synology_username: string;
|
||||
synology_password: string;
|
||||
synology_skip_ssl: boolean;
|
||||
}
|
||||
|
||||
interface SynologySettings {
|
||||
synology_url: string;
|
||||
synology_username: string;
|
||||
synology_skip_ssl: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -84,7 +120,7 @@ interface SynologyPhotoItem {
|
||||
|
||||
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
|
||||
try {
|
||||
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||
|
||||
if (!row) {
|
||||
return fail('User not found', 404);
|
||||
@@ -102,7 +138,7 @@ function _readSynologyUser(userId: number, columns: string[]): ServiceResult<Syn
|
||||
}
|
||||
|
||||
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
|
||||
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
||||
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
|
||||
if (!user.success) return user as ServiceResult<SynologyCredentials>;
|
||||
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
|
||||
const password = decrypt_api_key(user.data.synology_password);
|
||||
@@ -111,6 +147,7 @@ function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredenti
|
||||
synology_url: user.data.synology_url,
|
||||
synology_username: user.data.synology_username,
|
||||
synology_password: password,
|
||||
synology_skip_ssl: user.data.synology_skip_ssl !== 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +166,7 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
|
||||
return body;
|
||||
}
|
||||
|
||||
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
|
||||
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams, skipSsl = true): Promise<ServiceResult<T>> {
|
||||
const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`);
|
||||
try {
|
||||
const resp = await safeFetch(endpoint, {
|
||||
@@ -139,12 +176,20 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000) as any,
|
||||
});
|
||||
}, { rejectUnauthorized: !skipSsl });
|
||||
if (!resp.ok) {
|
||||
return fail('Synology API request failed with status ' + resp.status, resp.status);
|
||||
}
|
||||
const response = await resp.json() as SynologyApiResponse<T>;
|
||||
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
|
||||
if (!response.success) {
|
||||
const code = response.error.code;
|
||||
const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')';
|
||||
// Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi.
|
||||
// All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes.
|
||||
const httpStatus = [106, 107, 119].includes(code) ? code : 400;
|
||||
return fail(message, httpStatus);
|
||||
}
|
||||
return success(response.data);
|
||||
} catch (error) {
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
return fail(error.message, 400);
|
||||
@@ -153,25 +198,41 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
|
||||
}
|
||||
}
|
||||
|
||||
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
|
||||
const SYNOLOGY_DEVICE_NAME = 'trek';
|
||||
|
||||
async function _loginToSynology(
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {},
|
||||
): Promise<ServiceResult<{ sid: string; did?: string }>> {
|
||||
const { otp, deviceId, skipSsl = false } = opts;
|
||||
const body = new URLSearchParams({
|
||||
api: 'SYNO.API.Auth',
|
||||
method: 'login',
|
||||
version: '3',
|
||||
version: '6',
|
||||
account: username,
|
||||
passwd: password,
|
||||
format: 'sid',
|
||||
client: 'browser',
|
||||
device_name: SYNOLOGY_DEVICE_NAME,
|
||||
});
|
||||
if (otp && otp.trim()) {
|
||||
body.append('otp_code', otp.trim());
|
||||
body.append('enable_device_token', 'yes');
|
||||
}
|
||||
if (deviceId) {
|
||||
body.append('device_id', deviceId);
|
||||
}
|
||||
|
||||
const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
|
||||
const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl);
|
||||
if (!result.success) {
|
||||
return result as ServiceResult<string>;
|
||||
return result as ServiceResult<{ sid: string; did?: string }>;
|
||||
}
|
||||
if (!result.data.sid) {
|
||||
return fail('Failed to get session ID from Synology', 500);
|
||||
}
|
||||
return success(result.data.sid);
|
||||
|
||||
|
||||
return success({ sid: result.data.sid, did: result.data.did });
|
||||
}
|
||||
|
||||
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
|
||||
@@ -185,8 +246,9 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
|
||||
return session as ServiceResult<T>;
|
||||
}
|
||||
|
||||
const skipSsl = creds.data.synology_skip_ssl;
|
||||
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
|
||||
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body);
|
||||
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
|
||||
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
|
||||
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
|
||||
_clearSynologySID(userId);
|
||||
@@ -194,7 +256,7 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
|
||||
if (!retrySession.success || !retrySession.data) {
|
||||
return retrySession as ServiceResult<T>;
|
||||
}
|
||||
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
|
||||
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -232,6 +294,10 @@ function _clearSynologySID(userId: number): void {
|
||||
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
function _clearSynologySession(userId: number): void {
|
||||
db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
|
||||
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
|
||||
// The first segment must be a non-empty integer (the unit ID used for API calls).
|
||||
@@ -241,9 +307,9 @@ function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string;
|
||||
}
|
||||
|
||||
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
|
||||
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
|
||||
if (cachedSid.success && cachedSid.data?.synology_sid) {
|
||||
const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid);
|
||||
const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']);
|
||||
if (cached.success && cached.data?.synology_sid) {
|
||||
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
|
||||
if (decryptedSid) return success(decryptedSid);
|
||||
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
|
||||
_clearSynologySID(userId);
|
||||
@@ -254,15 +320,22 @@ async function _getSynologySession(userId: number): Promise<ServiceResult<string
|
||||
return creds as ServiceResult<string>;
|
||||
}
|
||||
|
||||
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
|
||||
// Use stored device ID to skip OTP on re-login (trusted device flow)
|
||||
const storedDid = cached.success && cached.data?.synology_did
|
||||
? (decrypt_api_key(cached.data.synology_did) || undefined)
|
||||
: undefined;
|
||||
|
||||
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, {
|
||||
deviceId: storedDid,
|
||||
skipSsl: creds.data.synology_skip_ssl,
|
||||
});
|
||||
|
||||
if (!resp.success) {
|
||||
return resp as ServiceResult<string>;
|
||||
}
|
||||
|
||||
const encrypted = encrypt_api_key(resp.data);
|
||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId);
|
||||
return success(resp.data);
|
||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
|
||||
return success(resp.data.sid);
|
||||
}
|
||||
|
||||
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
|
||||
@@ -272,11 +345,12 @@ export async function getSynologySettings(userId: number): Promise<ServiceResult
|
||||
return success({
|
||||
synology_url: creds.data.synology_url || '',
|
||||
synology_username: creds.data.synology_username || '',
|
||||
synology_skip_ssl: creds.data.synology_skip_ssl,
|
||||
connected: session.success,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> {
|
||||
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise<ServiceResult<string>> {
|
||||
|
||||
const ssrf = await checkSsrf(synologyUrl);
|
||||
if (!ssrf.allowed) {
|
||||
@@ -291,24 +365,42 @@ export async function updateSynologySettings(userId: number, synologyUrl: string
|
||||
return fail('No stored password found. Please provide a password to save settings.', 400);
|
||||
}
|
||||
|
||||
// Only invalidate the session when the account itself changes (different URL or username).
|
||||
// If the user just tested the connection, testSynologyConnection already stored a fresh
|
||||
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
|
||||
const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']);
|
||||
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
|
||||
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
|
||||
const sessionCleared = urlChanged || userChanged;
|
||||
if (sessionCleared) {
|
||||
_clearSynologySession(userId);
|
||||
sendNotification({
|
||||
event: 'synology_session_cleared',
|
||||
actorId: null,
|
||||
params: {},
|
||||
scope: 'user',
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
|
||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
|
||||
synologyUrl,
|
||||
synologyUsername,
|
||||
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
|
||||
synologySkipSsl ? 1 : 0,
|
||||
userId,
|
||||
);
|
||||
} catch {
|
||||
return fail('Failed to update Synology settings', 500);
|
||||
}
|
||||
|
||||
_clearSynologySID(userId);
|
||||
return success("settings updated");
|
||||
return success('settings updated');
|
||||
}
|
||||
|
||||
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
|
||||
const sid = await _getSynologySession(userId);
|
||||
if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message });
|
||||
if ('error' in sid) return success({ connected: false, error: sid.error.message });
|
||||
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
|
||||
try {
|
||||
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
|
||||
@@ -318,17 +410,25 @@ export async function getSynologyStatus(userId: number): Promise<ServiceResult<S
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> {
|
||||
export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise<ServiceResult<StatusResult>> {
|
||||
|
||||
const ssrf = await checkSsrf(synologyUrl);
|
||||
if (!ssrf.allowed) {
|
||||
return fail(ssrf.error, 400);
|
||||
}
|
||||
|
||||
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
|
||||
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl });
|
||||
if ('error' in resp) {
|
||||
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
|
||||
return success({ connected: false, error: resp.error.message });
|
||||
}
|
||||
|
||||
// Persist the session so the OTP code is not required again on save.
|
||||
// The did (device token) allows future re-logins without OTP.
|
||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
|
||||
if (resp.data.did) {
|
||||
db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
|
||||
}
|
||||
|
||||
return success({ connected: true, user: { name: synologyUsername } });
|
||||
}
|
||||
|
||||
@@ -352,6 +452,36 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
|
||||
}
|
||||
|
||||
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(albumId),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||
const items = result.data.list || [];
|
||||
allItems.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
const assets = allItems.map(item => ({
|
||||
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
|
||||
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
|
||||
})).filter(a => a.id);
|
||||
|
||||
return success({ assets, total: assets.length, hasMore: false });
|
||||
}
|
||||
|
||||
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
|
||||
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||
@@ -455,7 +585,6 @@ export async function streamSynologyAsset(
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
kind: 'thumbnail' | 'original',
|
||||
size?: string,
|
||||
): Promise<void> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) {
|
||||
@@ -479,6 +608,8 @@ export async function streamSynologyAsset(
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||
const params = kind === 'thumbnail'
|
||||
? new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
@@ -487,7 +618,7 @@ export async function streamSynologyAsset(
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: size,
|
||||
size: 'sm',
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mapDbError,
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
|
||||
|
||||
function _providers(): Array<{id: string; enabled: boolean}> {
|
||||
@@ -45,13 +46,14 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult<an
|
||||
}
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
|
||||
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
|
||||
u.username, u.avatar
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.trip_id = ?
|
||||
AND (tp.user_id = ? OR tp.shared = 1)
|
||||
AND tp.provider IN (${enabledProviders.map(() => '?').join(',')})
|
||||
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
|
||||
ORDER BY tp.added_at ASC
|
||||
`).all(tripId, userId, ...enabledProviders);
|
||||
|
||||
@@ -108,9 +110,10 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId
|
||||
return providerResult as ServiceResult<boolean>;
|
||||
}
|
||||
try {
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null);
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
|
||||
return success(result.changes > 0);
|
||||
}
|
||||
catch (error) {
|
||||
@@ -163,8 +166,7 @@ export async function addTripPhotos(
|
||||
export async function setTripPhotoSharing(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
photoId: number,
|
||||
shared: boolean,
|
||||
sid?: string,
|
||||
): Promise<ServiceResult<true>> {
|
||||
@@ -179,9 +181,8 @@ export async function setTripPhotoSharing(
|
||||
SET shared = ?
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
|
||||
AND photo_id = ?
|
||||
`).run(shared ? 1 : 0, tripId, userId, photoId);
|
||||
|
||||
await _notifySharedTripPhotos(tripId, userId, 1);
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
@@ -194,8 +195,7 @@ export async function setTripPhotoSharing(
|
||||
export function removeTripPhoto(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
photoId: number,
|
||||
sid?: string,
|
||||
): ServiceResult<true> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
@@ -208,9 +208,8 @@ export function removeTripPhoto(
|
||||
DELETE FROM trip_photos
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(tripId, userId, assetId, provider);
|
||||
AND photo_id = ?
|
||||
`).run(tripId, userId, photoId);
|
||||
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ export type NotifEventType =
|
||||
| 'photos_shared'
|
||||
| 'collab_message'
|
||||
| 'packing_tagged'
|
||||
| 'version_available';
|
||||
| 'version_available'
|
||||
| 'synology_session_cleared';
|
||||
|
||||
export interface AvailableChannels {
|
||||
email: boolean;
|
||||
@@ -32,6 +33,7 @@ const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||
collab_message: ['inapp', 'email', 'webhook'],
|
||||
packing_tagged: ['inapp', 'email', 'webhook'],
|
||||
version_available: ['inapp', 'email', 'webhook'],
|
||||
synology_session_cleared: ['inapp'],
|
||||
};
|
||||
|
||||
/** Events that target admins only (shown in admin panel, not in user settings). */
|
||||
|
||||
@@ -112,6 +112,12 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
synology_session_cleared: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notifications.synologySessionCleared.title',
|
||||
textKey: 'notifications.synologySessionCleared.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Fallback config for unknown event types ────────────────────────────────
|
||||
|
||||
@@ -105,6 +105,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.' }),
|
||||
},
|
||||
de: {
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
||||
@@ -115,6 +116,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.' }),
|
||||
},
|
||||
fr: {
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
||||
@@ -125,6 +127,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
|
||||
synology_session_cleared: () => ({ title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.' }),
|
||||
},
|
||||
es: {
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
||||
@@ -135,6 +138,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.' }),
|
||||
},
|
||||
nl: {
|
||||
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
||||
@@ -145,6 +149,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.' }),
|
||||
},
|
||||
ru: {
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
||||
@@ -155,6 +160,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
|
||||
synology_session_cleared: () => ({ title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.' }),
|
||||
},
|
||||
zh: {
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
||||
@@ -165,6 +171,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。' }),
|
||||
},
|
||||
'zh-TW': {
|
||||
trip_invite: p => ({ title: `邀請加入「${p.trip}」`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }),
|
||||
@@ -174,6 +181,8 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }),
|
||||
collab_message: p => ({ title: `「${p.trip}」中的新訊息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `打包清單:${p.category}`, body: `${p.actor} 已將您指派到「${p.trip}」中的「${p.category}」分類。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。' }),
|
||||
},
|
||||
ar: {
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
||||
@@ -184,6 +193,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
|
||||
synology_session_cleared: () => ({ title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.' }),
|
||||
},
|
||||
br: {
|
||||
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
|
||||
@@ -194,6 +204,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.' }),
|
||||
},
|
||||
cs: {
|
||||
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
|
||||
@@ -204,6 +215,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
|
||||
synology_session_cleared: () => ({ title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.' }),
|
||||
},
|
||||
hu: {
|
||||
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
|
||||
@@ -214,6 +226,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
|
||||
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
|
||||
synology_session_cleared: () => ({ title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.' }),
|
||||
},
|
||||
it: {
|
||||
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
|
||||
@@ -224,6 +237,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.' }),
|
||||
},
|
||||
pl: {
|
||||
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
|
||||
@@ -234,6 +248,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
|
||||
synology_session_cleared: () => ({ title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.' }),
|
||||
},
|
||||
id: {
|
||||
trip_invite: p => ({ title: `Undangan perjalanan: "${p.trip}"`, body: `${p.actor} mengundang ${p.invitee || 'seorang anggota'} ke perjalanan "${p.trip}".` }),
|
||||
@@ -395,9 +410,24 @@ export async function sendWebhook(url: string, payload: { event: string; title:
|
||||
}
|
||||
|
||||
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' };
|
||||
try {
|
||||
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
|
||||
return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
|
||||
const config = getSmtpConfig()!;
|
||||
const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
|
||||
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: 'TREK — Test Notification',
|
||||
text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.',
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { isAddonEnabled } from './adminService';
|
||||
import { validateScopes } from '../mcp/scopes';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { User } from '../types';
|
||||
import { writeAudit, logWarn } from './auditLog';
|
||||
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
|
||||
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
|
||||
const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
// PKCE format (RFC 7636)
|
||||
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||
const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory auth code store (short-lived, no need for DB persistence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PendingCode {
|
||||
clientId: string;
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const MAX_PENDING_CODES = 500;
|
||||
const pendingCodes = new Map<string, PendingCode>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of pendingCodes) {
|
||||
if (now > entry.expiresAt) pendingCodes.delete(key);
|
||||
}
|
||||
}, 60_000).unref();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB row types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OAuthClientRow {
|
||||
id: string;
|
||||
user_id: number;
|
||||
name: string;
|
||||
client_id: string;
|
||||
client_secret_hash: string;
|
||||
redirect_uris: string; // JSON array
|
||||
allowed_scopes: string; // JSON array
|
||||
created_at: string;
|
||||
is_public: number; // 0 | 1 (SQLite boolean)
|
||||
created_via: string; // 'settings_ui' | 'browser-registration'
|
||||
}
|
||||
|
||||
interface OAuthTokenRow {
|
||||
id: number;
|
||||
client_id: string;
|
||||
user_id: number;
|
||||
access_token_hash: string;
|
||||
refresh_token_hash: string;
|
||||
scopes: string; // JSON array
|
||||
access_token_expires_at: string;
|
||||
refresh_token_expires_at: string;
|
||||
revoked_at: string | null;
|
||||
parent_token_id: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
/** Constant-time comparison of two hex-encoded SHA-256 hashes. */
|
||||
function timingSafeEqualHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function generateAccessToken(): string {
|
||||
return 'trekoa_' + randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function generateRefreshToken(): string {
|
||||
return 'trekrf_' + randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client management (self-service, gated by MCP addon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as OAuthClientRow[];
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
is_public: Boolean(r.is_public),
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Returns true if the URI is a valid OAuth redirect target (HTTPS or localhost). */
|
||||
export function isValidRedirectUri(uri: string): boolean {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
return url.protocol === 'https:' || url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuthClient(
|
||||
userId: number | null,
|
||||
name: string,
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
ip?: string | null,
|
||||
options?: { isPublic?: boolean; createdVia?: string },
|
||||
): { error?: string; status?: number; client?: Record<string, unknown> } {
|
||||
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
|
||||
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
|
||||
|
||||
for (const uri of redirectUris) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch {
|
||||
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
||||
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
|
||||
const { valid, invalid } = validateScopes(allowedScopes);
|
||||
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
|
||||
|
||||
if (userId !== null) {
|
||||
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
|
||||
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
|
||||
} else {
|
||||
// Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse
|
||||
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL').get() as { count: number }).count;
|
||||
if (count >= 500) return { error: 'server_error', status: 503 };
|
||||
}
|
||||
|
||||
const isPublic = options?.isPublic ?? false;
|
||||
const createdVia = options?.createdVia ?? 'settings_ui';
|
||||
const id = randomUUID();
|
||||
const clientId = randomUUID();
|
||||
// Public clients have no usable secret; store an opaque random value to satisfy NOT NULL.
|
||||
const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
|
||||
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
|
||||
).get(id) as OAuthClientRow;
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
|
||||
|
||||
return {
|
||||
client: {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
name: row.name,
|
||||
client_id: row.client_id,
|
||||
redirect_uris: JSON.parse(row.redirect_uris),
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
created_at: row.created_at,
|
||||
is_public: Boolean(row.is_public),
|
||||
created_via: row.created_via,
|
||||
// client_secret only present for confidential clients — shown once, not stored in plain text
|
||||
...(rawSecret ? { client_secret: rawSecret } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function rotateOAuthClientSecret(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; client_secret?: string } {
|
||||
const row = db.prepare('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
|
||||
|
||||
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
|
||||
const secretHash = hashToken(rawSecret);
|
||||
|
||||
db.prepare('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?').run(secretHash, clientRowId);
|
||||
|
||||
// Revoke all existing tokens for this client so old sessions are invalidated
|
||||
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL").run(row.client_id);
|
||||
|
||||
// Terminate active MCP sessions for this (user, client) pair
|
||||
|
||||
revokeUserSessionsForClient(userId, row.client_id);
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
|
||||
|
||||
return { client_secret: rawSecret };
|
||||
}
|
||||
|
||||
export function deleteOAuthClient(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
|
||||
writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth code (in-memory, 2-minute TTL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createAuthCode(params: {
|
||||
clientId: string;
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
}): string | null {
|
||||
if (pendingCodes.size >= MAX_PENDING_CODES) return null;
|
||||
const rawCode = randomBytes(32).toString('hex');
|
||||
pendingCodes.set(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
|
||||
return rawCode;
|
||||
}
|
||||
|
||||
export function consumeAuthCode(code: string): PendingCode | null {
|
||||
const entry = pendingCodes.get(code);
|
||||
if (!entry) return null;
|
||||
pendingCodes.delete(code);
|
||||
if (Date.now() > entry.expiresAt) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consent management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getConsent(clientId: string, userId: number): string[] | null {
|
||||
const row = db.prepare(
|
||||
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?'
|
||||
).get(clientId, userId) as { scopes: string } | undefined;
|
||||
return row ? JSON.parse(row.scopes) : null;
|
||||
}
|
||||
|
||||
export function saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
|
||||
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
|
||||
const existing = getConsent(clientId, userId) ?? [];
|
||||
const merged = Array.from(new Set([...existing, ...scopes]));
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(clientId, userId, JSON.stringify(merged));
|
||||
writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
|
||||
}
|
||||
|
||||
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
|
||||
return requestedScopes.every(s => existingScopes.includes(s));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token issuance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function issueTokens(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
parentTokenId: number | null = null,
|
||||
): {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
const rawAccess = generateAccessToken();
|
||||
const rawRefresh = generateRefreshToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
const refreshHash = hashToken(rawRefresh);
|
||||
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
|
||||
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO oauth_tokens
|
||||
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
|
||||
|
||||
return {
|
||||
access_token: rawAccess,
|
||||
refresh_token: rawRefresh,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
scope: scopes.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token verification (used by MCP handler on every request)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OAuthTokenInfo {
|
||||
user: User;
|
||||
scopes: string[];
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
|
||||
const hash = hashToken(rawToken);
|
||||
const row = db.prepare(`
|
||||
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
|
||||
ot.user_id, ot.client_id, u.username, u.email, u.role
|
||||
FROM oauth_tokens ot
|
||||
JOIN users u ON ot.user_id = u.id
|
||||
WHERE ot.access_token_hash = ?
|
||||
`).get(hash) as (OAuthTokenRow & { username: string; email: string; role: string }) | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
if (row.revoked_at) return null;
|
||||
if (new Date(row.access_token_expires_at) < new Date()) return null;
|
||||
|
||||
return {
|
||||
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
|
||||
scopes: JSON.parse(row.scopes),
|
||||
clientId: row.client_id,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token refresh (rotation + replay detection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
|
||||
function findChainRoot(tokenId: number): number {
|
||||
let current = tokenId;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const row = db.prepare('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?').get(current) as { id: number; parent_token_id: number | null } | undefined;
|
||||
if (!row || row.parent_token_id === null) return current;
|
||||
current = row.parent_token_id;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
|
||||
function revokeChain(rootId: number): number[] {
|
||||
const rows = db.prepare(`
|
||||
WITH RECURSIVE chain(id) AS (
|
||||
SELECT id FROM oauth_tokens WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
|
||||
)
|
||||
SELECT id FROM chain
|
||||
`).all(rootId) as { id: number }[];
|
||||
const ids = rows.map(r => r.id);
|
||||
if (ids.length > 0) {
|
||||
db.prepare(
|
||||
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`
|
||||
).run(...ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function refreshTokens(
|
||||
rawRefreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string | undefined,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
|
||||
const client = db.prepare('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
|
||||
if (!client) return { error: 'invalid_client', status: 401 };
|
||||
if (!client.is_public) {
|
||||
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
|
||||
return { error: 'invalid_client', status: 401 };
|
||||
}
|
||||
}
|
||||
|
||||
const hash = hashToken(rawRefreshToken);
|
||||
const row = db.prepare(`
|
||||
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
|
||||
FROM oauth_tokens WHERE refresh_token_hash = ?
|
||||
`).get(hash) as OAuthTokenRow | undefined;
|
||||
|
||||
if (!row) return { error: 'invalid_grant', status: 400 };
|
||||
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
|
||||
|
||||
// ---- Replay detection (C3) ----
|
||||
if (row.revoked_at) {
|
||||
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
|
||||
const rootId = findChainRoot(row.id);
|
||||
revokeChain(rootId);
|
||||
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
writeAudit({
|
||||
userId: row.user_id,
|
||||
action: 'oauth.token.replay_detected',
|
||||
details: { client_id: clientId },
|
||||
ip,
|
||||
});
|
||||
logWarn(`[OAuth] Refresh token replay detected for user=${row.user_id} client=${clientId} ip=${ip ?? '-'}`);
|
||||
|
||||
return { error: 'invalid_grant', status: 400 };
|
||||
}
|
||||
|
||||
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
|
||||
|
||||
// Revoke old pair immediately (rotation) and issue new pair linked to old row
|
||||
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
|
||||
|
||||
// Terminate active MCP sessions for the old token's client so client must re-authenticate
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
|
||||
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
|
||||
return { tokens };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token revocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
|
||||
const hash = hashToken(rawToken);
|
||||
|
||||
// Get the user_id for the token so we can revoke its MCP sessions
|
||||
const row = db.prepare(
|
||||
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?'
|
||||
).get(hash, hash, clientId) as { user_id: number } | undefined;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE oauth_tokens
|
||||
SET revoked_at = CURRENT_TIMESTAMP
|
||||
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
|
||||
`).run(hash, hash, clientId);
|
||||
|
||||
const affectedUserId = row?.user_id ?? userId;
|
||||
if (affectedUserId) {
|
||||
|
||||
revokeUserSessionsForClient(affectedUserId, clientId);
|
||||
writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active session listing (for user settings page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listOAuthSessions(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT ot.id, ot.client_id, oc.name AS client_name, 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
|
||||
WHERE ot.user_id = ?
|
||||
AND ot.revoked_at IS NULL
|
||||
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY ot.created_at DESC
|
||||
`).all(userId) as Record<string, unknown>[];
|
||||
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
|
||||
}
|
||||
|
||||
export function revokeSession(
|
||||
userId: number,
|
||||
sessionId: number,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
const row = db.prepare('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId) as { 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(sessionId);
|
||||
|
||||
|
||||
revokeUserSessionsForClient(userId, row.client_id);
|
||||
|
||||
writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthorizeParams {
|
||||
response_type: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
}
|
||||
|
||||
export interface ValidateAuthorizeResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
client?: { name: string; allowed_scopes: string[] };
|
||||
scopes?: string[];
|
||||
/** true when user is logged in but consent UI must be shown */
|
||||
consentRequired?: boolean;
|
||||
/** true when the request is valid but user is not authenticated */
|
||||
loginRequired?: boolean;
|
||||
/** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */
|
||||
scopeSelectable?: boolean;
|
||||
}
|
||||
|
||||
export function validateAuthorizeRequest(
|
||||
params: AuthorizeParams,
|
||||
userId: number | null,
|
||||
): ValidateAuthorizeResult {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
|
||||
}
|
||||
|
||||
if (params.response_type !== 'code') {
|
||||
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
|
||||
}
|
||||
|
||||
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
|
||||
}
|
||||
|
||||
// H1: Enforce code_challenge format (RFC 7636 §4.2)
|
||||
if (!CODE_CHALLENGE_RE.test(params.code_challenge)) {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'code_challenge must be 43 base64url characters (S256)' };
|
||||
}
|
||||
|
||||
if (!params.client_id) {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
|
||||
}
|
||||
|
||||
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(params.client_id) as OAuthClientRow | undefined;
|
||||
if (!client) {
|
||||
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
|
||||
}
|
||||
|
||||
const allowedUris: string[] = JSON.parse(client.redirect_uris);
|
||||
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
|
||||
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
|
||||
}
|
||||
|
||||
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
|
||||
if (requestedScopes.length === 0) {
|
||||
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
|
||||
}
|
||||
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
// Narrow to the intersection: drop scopes the client isn't permitted for rather
|
||||
// than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing).
|
||||
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
|
||||
if (grantedScopes.length === 0) {
|
||||
return { valid: false, error: 'invalid_scope', error_description: 'None of the requested scopes are permitted for this client' };
|
||||
}
|
||||
|
||||
if (userId === null) {
|
||||
// H3: return only the minimum required fields — do NOT expose scopes, client.name, or
|
||||
// allowed_scopes to unauthenticated callers to prevent client enumeration.
|
||||
return { valid: true, loginRequired: true };
|
||||
}
|
||||
|
||||
const existingConsent = getConsent(params.client_id, userId);
|
||||
const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, grantedScopes);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
client: { name: client.name, allowed_scopes: allowedScopes },
|
||||
scopes: grantedScopes,
|
||||
consentRequired,
|
||||
scopeSelectable: client.created_via === 'dcr',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
|
||||
// H1: validate code_verifier format before hashing
|
||||
if (!CODE_VERIFIER_RE.test(codeVerifier)) return false;
|
||||
|
||||
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
// Constant-time compare (both are base64url strings of equal length for S256)
|
||||
if (expected.length !== codeChallenge.length) return false;
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client authentication (for token endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
|
||||
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
|
||||
if (!client) return null;
|
||||
if (client.is_public) {
|
||||
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
|
||||
return client;
|
||||
}
|
||||
// H4: constant-time comparison to prevent timing side-channel
|
||||
if (!clientSecret) return null;
|
||||
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
|
||||
return client;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { User } from '../types';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { resolveAuthToggles } from './authService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -269,10 +270,8 @@ export function findOrCreateUser(
|
||||
}
|
||||
|
||||
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') {
|
||||
const { oidc_registration } = resolveAuthToggles();
|
||||
if (!oidc_registration) {
|
||||
return { error: 'registration_disabled' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import unzipper from 'unzipper';
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
import { Place } from '../types';
|
||||
import {
|
||||
buildCategoryNameLookup,
|
||||
createKmlImportSummary,
|
||||
decodeUtf8WithWarning,
|
||||
extractKmlPlacemarkNodes,
|
||||
parsePlacemarkNode,
|
||||
resolveCategoryIdForFolder,
|
||||
type KmlImportSummary,
|
||||
} from './kmlImport';
|
||||
|
||||
interface PlaceWithCategory extends Place {
|
||||
category_name: string | null;
|
||||
@@ -14,6 +25,12 @@ interface UnsplashSearchResponse {
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface PlaceImportResult {
|
||||
places: any[];
|
||||
count: number;
|
||||
summary: KmlImportSummary;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List places
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -233,6 +250,82 @@ const gpxParser = new XMLParser({
|
||||
isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name),
|
||||
});
|
||||
|
||||
const kmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name),
|
||||
// Treat <description> as raw text so mixed-content HTML (e.g. <br/>, <i>)
|
||||
// is returned as a string instead of a parsed object.
|
||||
stopNodes: ['*.description'],
|
||||
});
|
||||
|
||||
export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import deduplication helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m
|
||||
|
||||
interface DedupSet {
|
||||
names: Set<string>;
|
||||
coords: Array<{ lat: number; lng: number }>;
|
||||
}
|
||||
|
||||
/** Build a lookup of names/coords for places already in a trip. */
|
||||
function buildDedupSet(tripId: string): DedupSet {
|
||||
const rows = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(tripId) as Array<{
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
}>;
|
||||
const names = new Set<string>();
|
||||
const coords: Array<{ lat: number; lng: number }> = [];
|
||||
for (const row of rows) {
|
||||
if (row.name) {
|
||||
names.add(row.name.trim().toLowerCase());
|
||||
} else if (row.lat != null && row.lng != null) {
|
||||
coords.push({ lat: row.lat, lng: row.lng });
|
||||
}
|
||||
}
|
||||
return { names, coords };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a candidate place is already represented in the dedup set.
|
||||
* Named places match by case-insensitive name; unnamed places fall back to
|
||||
* coordinate proximity.
|
||||
*/
|
||||
function isPlaceDuplicate(
|
||||
candidate: { name: string | null | undefined; lat: number | null; lng: number | null },
|
||||
dedup: DedupSet,
|
||||
): boolean {
|
||||
const normalizedName = candidate.name?.trim().toLowerCase();
|
||||
if (normalizedName) return dedup.names.has(normalizedName);
|
||||
if (candidate.lat != null && candidate.lng != null) {
|
||||
return dedup.coords.some(
|
||||
(c) =>
|
||||
Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE &&
|
||||
Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
||||
function trackInsertedInDedupSet(
|
||||
place: { name: string | null | undefined; lat: number | null; lng: number | null },
|
||||
dedup: DedupSet,
|
||||
): void {
|
||||
const normalizedName = place.name?.trim().toLowerCase();
|
||||
if (normalizedName) {
|
||||
dedup.names.add(normalizedName);
|
||||
} else if (place.lat != null && place.lng != null) {
|
||||
dedup.coords.push({ lat: place.lat, lng: place.lng });
|
||||
}
|
||||
}
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
@@ -284,21 +377,153 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, ?, 'walking', ?)
|
||||
`);
|
||||
const created: any[] = [];
|
||||
let skipped = 0;
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
return created;
|
||||
return { places: created, count: created.length, skipped };
|
||||
}
|
||||
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
|
||||
const decoded = decodeUtf8WithWarning(fileBuffer);
|
||||
|
||||
const validationResult = XMLValidator.validate(decoded.text);
|
||||
if (validationResult !== true) {
|
||||
throw new Error('Malformed KML: invalid XML structure');
|
||||
}
|
||||
|
||||
const parsed = kmlParser.parse(decoded.text);
|
||||
const kmlRoot = parsed?.kml ?? parsed;
|
||||
|
||||
if (!kmlRoot || typeof kmlRoot !== 'object') {
|
||||
throw new Error('Malformed KML: could not parse XML');
|
||||
}
|
||||
|
||||
const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot);
|
||||
const summary = createKmlImportSummary(placemarkNodes.length);
|
||||
|
||||
if (decoded.warning) {
|
||||
summary.warnings.push(decoded.warning);
|
||||
}
|
||||
|
||||
const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[];
|
||||
const categoryLookup = buildCategoryNameLookup(categories);
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const created: any[] = [];
|
||||
let dupCount = 0;
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
let fallbackIndex = 1;
|
||||
for (const node of placemarkNodes) {
|
||||
const parsedPlacemark = parsePlacemarkNode(node);
|
||||
|
||||
// KML geometry support is intentionally limited to <Placemark><Point> coordinates.
|
||||
if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) {
|
||||
summary.skippedCount += 1;
|
||||
summary.errors.push(`Skipped Placemark ${fallbackIndex}: missing Point coordinates.`);
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackName = `Placemark ${fallbackIndex}`;
|
||||
const name = parsedPlacemark.name || fallbackName;
|
||||
|
||||
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
|
||||
summary.skippedCount += 1;
|
||||
dupCount++;
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup);
|
||||
|
||||
const result = insertStmt.run(
|
||||
tripId,
|
||||
name,
|
||||
parsedPlacemark.description,
|
||||
parsedPlacemark.lat,
|
||||
parsedPlacemark.lng,
|
||||
categoryId,
|
||||
);
|
||||
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
|
||||
summary.createdCount += 1;
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
});
|
||||
|
||||
insertAll();
|
||||
|
||||
if (dupCount > 0) {
|
||||
summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`);
|
||||
}
|
||||
|
||||
if (summary.totalPlacemarks === 0) {
|
||||
summary.errors.push('No Placemarks found in KML file.');
|
||||
}
|
||||
|
||||
return { places: created, count: created.length, summary };
|
||||
}
|
||||
|
||||
export async function unpackKmzToKml(
|
||||
kmzBuffer: Buffer,
|
||||
decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT,
|
||||
): Promise<Buffer> {
|
||||
let zip;
|
||||
try {
|
||||
zip = await unzipper.Open.buffer(kmzBuffer);
|
||||
} catch {
|
||||
throw new Error('Invalid KMZ archive.');
|
||||
}
|
||||
|
||||
const kmlEntries = zip.files.filter((entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'));
|
||||
if (kmlEntries.length === 0) {
|
||||
throw new Error('KMZ archive does not contain a KML file.');
|
||||
}
|
||||
|
||||
const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0];
|
||||
|
||||
if (preferredEntry.uncompressedSize > decompressedSizeLimit) {
|
||||
throw new Error('KMZ archive exceeds the maximum allowed decompressed size.');
|
||||
}
|
||||
|
||||
return preferredEntry.buffer();
|
||||
}
|
||||
|
||||
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise<PlaceImportResult> {
|
||||
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
|
||||
return importKmlPlaces(tripId, kmlBuffer);
|
||||
}
|
||||
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string): Promise<PlaceImportResult> {
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer);
|
||||
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer);
|
||||
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -309,6 +534,10 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
let listId: string | null = null;
|
||||
let resolvedUrl = url;
|
||||
|
||||
// SSRF guard: validate user-supplied URL before fetching
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
||||
|
||||
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
|
||||
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||
@@ -374,22 +603,150 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
return { error: 'No places with coordinates found in list', status: 400 };
|
||||
}
|
||||
|
||||
// Insert places into trip
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const created: any[] = [];
|
||||
let skipped = 0;
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const p of places) {
|
||||
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
return { places: created, listName };
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import Naver Maps list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function importNaverList(
|
||||
tripId: string,
|
||||
url: string,
|
||||
): Promise<{ places: any[]; listName: string } | { error: string; status: number }> {
|
||||
let resolvedUrl = url;
|
||||
const limit = 20;
|
||||
|
||||
// SSRF guard: validate user-supplied URL before fetching
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
|
||||
|
||||
// Resolve naver.me short links to the canonical map.naver.com folder URL.
|
||||
let parsedUrl: URL;
|
||||
try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
|
||||
if (parsedUrl.hostname === 'naver.me') {
|
||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||
resolvedUrl = redirectRes.url;
|
||||
}
|
||||
|
||||
const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i);
|
||||
const folderId = folderMatch?.[1] || null;
|
||||
if (!folderId) {
|
||||
return { error: 'Could not extract folder ID from URL. Please use a shared Naver Maps list link.', status: 400 };
|
||||
}
|
||||
|
||||
const fetchPage = async (start: number) => {
|
||||
const apiUrl = `https://pages.map.naver.com/save-pages/api/maps-bookmark/v3/shares/${encodeURIComponent(folderId)}/bookmarks?placeInfo=true&start=${start}&limit=${limit}&sort=lastUseTime&mcids=ALL&createIdNo=true`;
|
||||
const apiRes = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!apiRes.ok) {
|
||||
return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiRes.json() as {
|
||||
folder?: { bookmarkCount?: number; name?: string };
|
||||
bookmarkList?: any[];
|
||||
};
|
||||
return { data } as const;
|
||||
} catch {
|
||||
return { error: 'Invalid list data received from Naver Maps', status: 400 } as const;
|
||||
}
|
||||
};
|
||||
|
||||
const firstPage = await fetchPage(0);
|
||||
if ('error' in firstPage) {
|
||||
return { error: firstPage.error, status: firstPage.status };
|
||||
}
|
||||
|
||||
const listName = firstPage.data.folder?.name || 'Naver Maps List';
|
||||
const totalCount = typeof firstPage.data.folder?.bookmarkCount === 'number'
|
||||
? firstPage.data.folder.bookmarkCount
|
||||
: (firstPage.data.bookmarkList?.length || 0);
|
||||
|
||||
const allItems: any[] = [...(firstPage.data.bookmarkList || [])];
|
||||
for (let start = limit; start < totalCount; start += limit) {
|
||||
const page = await fetchPage(start);
|
||||
if ('error' in page) {
|
||||
return { error: page.error, status: page.status };
|
||||
}
|
||||
const pageItems = page.data.bookmarkList || [];
|
||||
if (!Array.isArray(pageItems) || pageItems.length === 0) break;
|
||||
allItems.push(...pageItems);
|
||||
}
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return { error: 'List is empty or could not be read', status: 400 };
|
||||
}
|
||||
|
||||
const places: { name: string; lat: number; lng: number; notes: string | null; address: string | null }[] = [];
|
||||
for (const item of allItems) {
|
||||
const lat = Number(item?.py);
|
||||
const lng = Number(item?.px);
|
||||
const name = typeof item?.name === 'string' && item.name.trim()
|
||||
? item.name.trim()
|
||||
: (typeof item?.displayName === 'string' ? item.displayName.trim() : '');
|
||||
const note = typeof item?.memo === 'string' && item.memo.trim() ? item.memo.trim() : null;
|
||||
const address = typeof item?.address === 'string' && item.address.trim() ? item.address.trim() : null;
|
||||
|
||||
if (name && Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
places.push({ name, lat, lng, notes: note, address });
|
||||
}
|
||||
}
|
||||
|
||||
if (places.length === 0) {
|
||||
return { error: 'No places with coordinates found in list', status: 400 };
|
||||
}
|
||||
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const created: any[] = [];
|
||||
let skipped = 0;
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const p of places) {
|
||||
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,27 +34,44 @@ export { isOwner };
|
||||
|
||||
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number, dayCount?: number) {
|
||||
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
|
||||
// Helper: two-phase renumber to avoid UNIQUE(trip_id, day_number) collisions
|
||||
function renumber(days: { id: number }[]) {
|
||||
days.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
|
||||
days.forEach((d, i) => setDayNumber.run(i + 1, d.id));
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
|
||||
// Nullify all dated days instead of deleting them — preserves assignments/notes/accommodations
|
||||
const withDates = existing.filter(d => d.date);
|
||||
if (withDates.length > 0) {
|
||||
db.prepare(`DELETE FROM days WHERE trip_id = ? AND date IS NOT NULL`).run(tripId);
|
||||
const nullify = db.prepare('UPDATE days SET date = NULL WHERE id = ?');
|
||||
for (const d of withDates) nullify.run(d.id);
|
||||
}
|
||||
const targetCount = Math.min(Math.max(dayCount ?? (datelessExisting.length || 7), 1), MAX_TRIP_DAYS);
|
||||
const needed = targetCount - datelessExisting.length;
|
||||
// Now all days are dateless — adjust count toward dayCount target
|
||||
const allDays = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
|
||||
const targetCount = Math.min(Math.max(dayCount ?? (allDays.length || 7), 1), MAX_TRIP_DAYS);
|
||||
const needed = targetCount - allDays.length;
|
||||
if (needed > 0) {
|
||||
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
|
||||
for (let i = 0; i < needed; i++) insert.run(tripId, datelessExisting.length + i + 1);
|
||||
for (let i = 0; i < needed; i++) insert.run(tripId, allDays.length + i + 1);
|
||||
} else if (needed < 0) {
|
||||
const toRemove = datelessExisting.slice(targetCount);
|
||||
// Only trim trailing empty days to avoid destroying content
|
||||
const candidates = db.prepare(
|
||||
`SELECT d.id FROM days d
|
||||
WHERE d.trip_id = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id)
|
||||
ORDER BY d.day_number DESC
|
||||
LIMIT ?`
|
||||
).all(tripId, -needed) as { id: number }[];
|
||||
const del = db.prepare('DELETE FROM days WHERE id = ?');
|
||||
for (const d of toRemove) del.run(d.id);
|
||||
for (const d of candidates) del.run(d.id);
|
||||
}
|
||||
const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
|
||||
const tmpUpd = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
remaining.forEach((d, i) => tmpUpd.run(-(i + 1), d.id));
|
||||
remaining.forEach((d, i) => tmpUpd.run(i + 1, d.id));
|
||||
renumber(remaining);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,45 +90,50 @@ export function generateDays(tripId: number | bigint | string, startDate: string
|
||||
targetDates.push(`${yyyy}-${mm}-${dd}`);
|
||||
}
|
||||
|
||||
const existingByDate = new Map<string, { id: number; day_number: number; date: string | null }>();
|
||||
for (const d of existing) {
|
||||
if (d.date) existingByDate.set(d.date, d);
|
||||
}
|
||||
|
||||
const targetDateSet = new Set(targetDates);
|
||||
|
||||
const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date));
|
||||
// Split into dated (sorted by day_number = position) and dateless (spare pool)
|
||||
const dated = existing.filter(d => d.date).sort((a, b) => a.day_number - b.day_number);
|
||||
const dateless = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
|
||||
const del = db.prepare('DELETE FROM days WHERE id = ?');
|
||||
for (const d of toDelete) del.run(d.id);
|
||||
|
||||
// Reassign dateless days to the first unmatched target dates (preserves content)
|
||||
const assignDate = db.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?');
|
||||
let datelessIdx = 0;
|
||||
|
||||
const setTemp = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
const kept = existing.filter(d => d.date && targetDateSet.has(d.date));
|
||||
for (let i = 0; i < kept.length; i++) setTemp.run(-(i + 1), kept[i].id);
|
||||
// Phase 1: stamp all existing days with negative day_numbers to free up slots
|
||||
const allExisting = [...dated, ...dateless];
|
||||
allExisting.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
|
||||
|
||||
const assignDay = db.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?');
|
||||
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
|
||||
const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
|
||||
let datelessIdx = 0;
|
||||
|
||||
for (let i = 0; i < targetDates.length; i++) {
|
||||
const date = targetDates[i];
|
||||
const ex = existingByDate.get(date);
|
||||
if (ex) {
|
||||
update.run(i + 1, ex.id);
|
||||
if (i < dated.length) {
|
||||
// Positional remap: existing dated day i gets new date — keeps all children
|
||||
assignDay.run(date, i + 1, dated[i].id);
|
||||
} else if (datelessIdx < dateless.length) {
|
||||
// Reuse a dateless day — keeps its assignments, notes, etc.
|
||||
assignDate.run(date, i + 1, dateless[datelessIdx].id);
|
||||
assignDay.run(date, i + 1, dateless[datelessIdx].id);
|
||||
datelessIdx++;
|
||||
} else {
|
||||
insert.run(tripId, i + 1, date);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete any remaining unused dateless days
|
||||
for (let i = datelessIdx; i < dateless.length; i++) del.run(dateless[i].id);
|
||||
// Overflow dated days (trip shrunk): convert to dateless instead of deleting
|
||||
const nullify = db.prepare('UPDATE days SET date = NULL, day_number = ? WHERE id = ?');
|
||||
for (let i = targetDates.length; i < dated.length; i++) {
|
||||
nullify.run(targetDates.length + (i - targetDates.length) + 1, dated[i].id);
|
||||
}
|
||||
|
||||
// Any remaining unused dateless days: keep as dateless, just renumber.
|
||||
// Base must be max(targetDates.length, dated.length) to avoid colliding with
|
||||
// positives already assigned by the main loop or the overflow loop above.
|
||||
const maxAssigned = Math.max(targetDates.length, dated.length);
|
||||
for (let i = datelessIdx; i < dateless.length; i++) {
|
||||
setDayNumber.run(maxAssigned + (i - datelessIdx) + 1, dateless[i].id);
|
||||
}
|
||||
|
||||
// Final renumber to compact and eliminate any gaps/negatives
|
||||
const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
|
||||
renumber(remaining);
|
||||
}
|
||||
|
||||
// ── Trip CRUD ─────────────────────────────────────────────────────────────
|
||||
@@ -259,6 +281,18 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
|
||||
ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
|
||||
}
|
||||
|
||||
// Clean up journey entries synced from this trip before deleting
|
||||
// Delete skeleton entries (unfilled synced places)
|
||||
db.prepare(`
|
||||
DELETE FROM journey_entries
|
||||
WHERE source_trip_id = ? AND type = 'skeleton'
|
||||
`).run(tripId);
|
||||
// Detach filled entries (keep user's written content, just remove trip link)
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
|
||||
WHERE source_trip_id = ?
|
||||
`).run(tripId);
|
||||
|
||||
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
|
||||
|
||||
return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail };
|
||||
|
||||
@@ -185,10 +185,11 @@ export interface UpdatePlanBody {
|
||||
company_holidays_enabled?: boolean;
|
||||
carry_over_enabled?: boolean;
|
||||
weekend_days?: string;
|
||||
week_start?: number;
|
||||
}
|
||||
|
||||
export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) {
|
||||
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days } = body;
|
||||
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body;
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
@@ -198,6 +199,7 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId:
|
||||
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
|
||||
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
|
||||
if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); }
|
||||
if (week_start !== undefined) { updates.push('week_start = ?'); params.push(week_start === 0 ? 0 : 1); }
|
||||
|
||||
if (updates.length > 0) {
|
||||
params.push(planId);
|
||||
|
||||
@@ -197,7 +197,10 @@ export async function getWeather(
|
||||
if (diffDays > -1) {
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
const refYear = targetDate.getFullYear() - 1;
|
||||
let refYear = targetDate.getFullYear() - 1;
|
||||
// Archive API only has data up to yesterday — go back further if needed
|
||||
const yesterday = new Date(now.getTime() - 86400000);
|
||||
if (new Date(refYear, month - 1, day + 2) > yesterday) refYear--;
|
||||
const startDate = new Date(refYear, month - 1, day - 2);
|
||||
const endDate = new Date(refYear, month - 1, day + 2);
|
||||
const startStr = startDate.toISOString().slice(0, 10);
|
||||
@@ -299,7 +302,10 @@ export async function getDetailedWeather(
|
||||
|
||||
// Climate / archive path (> 16 days out)
|
||||
if (diffDays > 16) {
|
||||
const refYear = targetDate.getFullYear() - 1;
|
||||
let refYear = targetDate.getFullYear() - 1;
|
||||
// Archive API only has data up to yesterday — go back further if needed
|
||||
const yesterday = new Date(now.getTime() - 86400000);
|
||||
if (new Date(refYear, targetDate.getMonth(), targetDate.getDate()) > yesterday) refYear--;
|
||||
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
|
||||
|
||||
@@ -301,3 +301,83 @@ export interface Participant {
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
// ── Journey addon ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface Journey {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
cover_gradient?: string | null;
|
||||
cover_image?: string | null;
|
||||
status: 'draft' | 'active' | 'completed';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface JourneyEntry {
|
||||
id: number;
|
||||
journey_id: number;
|
||||
source_trip_id?: number | null;
|
||||
source_place_id?: number | null;
|
||||
author_id: number;
|
||||
type: 'entry' | 'checkin' | 'skeleton';
|
||||
title?: string | null;
|
||||
story?: string | null;
|
||||
entry_date: string;
|
||||
entry_time?: string | null;
|
||||
location_name?: string | null;
|
||||
location_lat?: number | null;
|
||||
location_lng?: number | null;
|
||||
mood?: string | null;
|
||||
weather?: string | null;
|
||||
tags?: string | null;
|
||||
visibility: 'private' | 'shared' | 'public';
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface TrekPhoto {
|
||||
id: number;
|
||||
provider: string;
|
||||
asset_id?: string | null;
|
||||
owner_id?: number | null;
|
||||
file_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface JourneyPhoto {
|
||||
id: number;
|
||||
entry_id: number;
|
||||
photo_id: number;
|
||||
caption?: string | null;
|
||||
sort_order: number;
|
||||
shared: number;
|
||||
created_at: number;
|
||||
// Joined from trek_photos for API responses
|
||||
provider?: string;
|
||||
asset_id?: string | null;
|
||||
owner_id?: number | null;
|
||||
file_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface JourneyTrip {
|
||||
journey_id: number;
|
||||
trip_id: number;
|
||||
added_at: number;
|
||||
}
|
||||
|
||||
export interface JourneyContributor {
|
||||
journey_id: number;
|
||||
user_id: number;
|
||||
role: 'owner' | 'editor' | 'viewer';
|
||||
added_at: number;
|
||||
}
|
||||
|
||||
@@ -114,17 +114,25 @@ export class SsrfBlockedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SafeFetchOptions {
|
||||
rejectUnauthorized?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes
|
||||
* the request using a DNS-pinned dispatcher so the resolved IP cannot change
|
||||
* between the check and the actual connection (DNS rebinding prevention).
|
||||
*
|
||||
* Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS
|
||||
* certificates (e.g. a Synology NAS on a local network). The SSRF guard still
|
||||
* applies — only the TLS certificate check is relaxed.
|
||||
*/
|
||||
export async function safeFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response> {
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
|
||||
}
|
||||
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!);
|
||||
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, options?.rejectUnauthorized ?? true);
|
||||
return fetch(url, { ...init, dispatcher } as any);
|
||||
}
|
||||
|
||||
@@ -133,9 +141,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise<Respon
|
||||
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
|
||||
* goes to the IP we checked, not a re-resolved one.
|
||||
*/
|
||||
export function createPinnedDispatcher(resolvedIp: string): Agent {
|
||||
export function createPinnedDispatcher(resolvedIp: string, rejectUnauthorized = true): Agent {
|
||||
return new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized,
|
||||
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
|
||||
const family = resolvedIp.includes(':') ? 6 : 4;
|
||||
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
|
||||
|
||||
Reference in New Issue
Block a user