Files
TREK/server/src/db/seeds.ts
T
Maurice ae80ad266d fix(immich): a server with a self-signed certificate can be connected, as the other providers can
Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
2026-09-23 17:41:25 +02:00

190 lines
13 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: 'immich', field_key: 'immich_allow_insecure_tls', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
{ provider_id: 'immich', field_key: 'immich_auto_upload', label: 'immichAutoUpload', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 5 },
{ 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 };