mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
2d7cb124b3
A trip can be bound to a folder, tag or space in a self-hosted document store, and documents then move in both directions: what someone uploads in TREK appears there, and what someone files there appears in TREK. Three decisions shape the rest. The connection belongs to the TRIP, not to a person. Every other integration here is per user, and photos are shared only through an opt-in flag; neither can deliver "everyone on the trip sees the same documents", because visibility would depend on whose credentials fetched a file, and a member without their own Paperless account would see nothing. So the trip owner binds the trip once, the server talks to the provider under that one identity, and TREK's own membership decides who sees what. The provider never learns that TREK has members. When the credential holder leaves the trip the binding goes to `orphaned` rather than quietly carrying on with an ex-member's token. TREK keeps the bytes. A synced document is an ordinary trip_files row, so download, trash, offline cache, PDF export and backup keep working untouched, and a provider outage does not blank out a trip that starts tomorrow. Deletion never crosses the boundary on its own. A document that disappears upstream is recorded as missing and waits for a human, and a run that sees more than 40 percent of its known documents vanish at once refuses the whole listing rather than acting on it: an unmounted share answers exactly like a mass delete. The core reconciles by enumerating both sides, because the weakest provider sets the model. A changed-since query cannot see a deletion, Papra does not move updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and Synology FileStation has no change feed at all. A webhook is therefore only ever "look now", never a source of truth. Echo is caught by comparing content, not timestamps: pushed_sha256 records what TREK itself wrote, so a provider reporting TREK's own upload is not mistaken for a stranger's edit. Provider differences are answered by capability data rather than by branching on an id: stable ids where they exist (Paperless document id, oc:fileid, driveId) and a normalised path where they do not, replace-in-place where the API offers it, and a content hash from the listing only where the provider really supplies one. Admins switch providers on under Admin, Addons, Documents, mirroring how photo providers hang off Journey, including the cascade and the 409 when the parent addon is off. The per-trip setup lives in the file manager, where the person has the context that makes the folder choice obvious.
188 lines
12 KiB
TypeScript
188 lines
12 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import crypto from 'crypto';
|
|
import { readEnv } from '../app-config';
|
|
import { seedDocumentProviders } from './document-provider-seed';
|
|
|
|
// bcrypt cost factor for the seeded admin password — kept in sync with authService.
|
|
const BCRYPT_COST = 12;
|
|
|
|
// 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 {
|
|
const oidc = readEnv().oidc;
|
|
if (!oidc.only) return false;
|
|
return !!(oidc.issuer && oidc.clientId);
|
|
}
|
|
|
|
function seedAdminAccount(db: Database.Database): void {
|
|
try {
|
|
const env_admin_email = readEnv().adminBootstrap.email;
|
|
const env_admin_pw = readEnv().adminBootstrap.password;
|
|
const adminEnvProvided = !!(env_admin_email || env_admin_pw);
|
|
|
|
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
|
if (userCount > 0) {
|
|
// ADMIN_EMAIL/ADMIN_PASSWORD only take effect on the first run (empty database). Once a
|
|
// user exists they are silently ignored — a common trip-up: people add the vars after the
|
|
// fact, restart, nothing changes, and there is no hint why. Say so instead of staying silent.
|
|
if (adminEnvProvided) {
|
|
console.warn('[admin] ADMIN_EMAIL/ADMIN_PASSWORD are set, but users already exist — these only apply on first run (empty database) and are being ignored.');
|
|
console.warn('[admin] Change an existing password from Settings after signing in, reset the admin (see the Troubleshooting wiki), or start with an empty data volume to re-run setup.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
|
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
|
// seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated.
|
|
if (readEnv().demo.enabled) return;
|
|
|
|
if (isOidcOnlyConfigured()) {
|
|
console.log('');
|
|
console.log('╔══════════════════════════════════════════════╗');
|
|
console.log('║ TREK — OIDC-Only Mode ║');
|
|
console.log('║ First SSO login will become admin. ║');
|
|
console.log('╚══════════════════════════════════════════════╝');
|
|
console.log('');
|
|
return;
|
|
}
|
|
|
|
const bcrypt = require('bcryptjs');
|
|
|
|
let password: string;
|
|
let email: string;
|
|
if (env_admin_email && env_admin_pw) {
|
|
password = env_admin_pw;
|
|
email = env_admin_email;
|
|
} else {
|
|
// A partial config (only one of the two) is an easy mistake: neither value is used and a
|
|
// generated password is created instead. Flag it so the chosen credentials silently not
|
|
// working isn't a surprise.
|
|
if (adminEnvProvided) {
|
|
console.warn('[admin] Only one of ADMIN_EMAIL/ADMIN_PASSWORD is set — both are required for a custom admin. Falling back to admin@trek.local with a generated password (shown below).');
|
|
}
|
|
password = crypto.randomBytes(12).toString('base64url');
|
|
email = 'admin@trek.local';
|
|
}
|
|
|
|
const hash = bcrypt.hashSync(password, BCRYPT_COST);
|
|
const username = 'admin';
|
|
|
|
db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)').run(username, email, hash, 'admin');
|
|
|
|
console.log('');
|
|
console.log('╔══════════════════════════════════════════════╗');
|
|
console.log('║ TREK — First Run: Admin Account Created ║');
|
|
console.log('╠══════════════════════════════════════════════╣');
|
|
console.log(`║ Email: ${email.padEnd(33)}║`);
|
|
console.log(`║ Password: ${password.padEnd(33)}║`);
|
|
console.log('╚══════════════════════════════════════════════╝');
|
|
console.log('');
|
|
} catch (err: unknown) {
|
|
console.error('[ERROR] Error seeding admin account:', err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
function seedCategories(db: Database.Database): void {
|
|
try {
|
|
const existingCats = db.prepare('SELECT COUNT(*) as count FROM categories').get() as { count: number };
|
|
if (existingCats.count === 0) {
|
|
const defaultCategories = [
|
|
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
|
|
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
|
|
{ name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
|
|
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
|
|
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
|
|
{ name: 'Activity', color: '#10b981', icon: '🎯' },
|
|
{ name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
|
|
{ name: 'Beach', color: '#06b6d4', icon: '🏖️' },
|
|
{ name: 'Nature', color: '#84cc16', icon: '🌿' },
|
|
{ name: 'Other', color: '#6366f1', icon: '📍' },
|
|
];
|
|
const insertCat = db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
|
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
|
|
console.log('Default categories seeded');
|
|
}
|
|
} catch (err: unknown) {
|
|
console.error('Error seeding categories:', err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
function seedAddons(db: Database.Database): void {
|
|
try {
|
|
const defaultAddons = [
|
|
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
|
{ id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
|
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
|
{ 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 a shared Naver Maps list', type: 'integration', icon: 'Link2', enabled: 1, 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: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', enabled: 0, sort_order: 7 },
|
|
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
|
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
|
|
{ id: 'dawarich', name: 'Dawarich', description: 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', type: 'integration', icon: 'Dawarich', enabled: 0, sort_order: 17 },
|
|
{ id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 },
|
|
{ id: 'collections', name: 'Collections', description: 'Personal place library — save places across trips into named lists, copy into any trip, share with others', type: 'global', icon: 'Bookmark', enabled: 0, sort_order: 16 },
|
|
];
|
|
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);
|
|
|
|
const providerRows = [
|
|
{
|
|
id: 'immich',
|
|
name: 'Immich',
|
|
description: 'Immich photo provider',
|
|
icon: 'Image',
|
|
enabled: 0,
|
|
sort_order: 0,
|
|
},
|
|
{
|
|
id: 'synologyphotos',
|
|
name: 'Synology Photos',
|
|
description: 'Synology Photos integration with separate account settings',
|
|
icon: 'Image',
|
|
enabled: 0,
|
|
sort_order: 1,
|
|
},
|
|
];
|
|
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
|
|
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', 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, 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.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
|
|
}
|
|
|
|
// Document providers live in their own pair of tables (see the migration
|
|
// for why they are not a `kind` column on photo_providers). Seeded from the
|
|
// same helper the migration uses, so a fresh install and an upgraded one
|
|
// agree.
|
|
seedDocumentProviders(db);
|
|
|
|
console.log('Default addons seeded');
|
|
} catch (err: unknown) {
|
|
console.error('Error seeding addons:', err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
function runSeeds(db: Database.Database): void {
|
|
seedAdminAccount(db);
|
|
seedCategories(db);
|
|
seedAddons(db);
|
|
}
|
|
|
|
export { runSeeds, seedAdminAccount };
|