refactor(memories): unified, immich and synology photo sync through repositories

This commit is contained in:
jubnl
2026-09-23 18:38:36 +02:00
parent 5453836a06
commit bceaf4b134
14 changed files with 857 additions and 128 deletions
@@ -30,4 +30,29 @@ export class PhotoProvidersRepository extends TrekRepository<PhotoProviders> {
const rows = await this.find({ enabled: 1 }, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as PhotoProviderRow);
}
/**
* UM1 (`UnifiedMemoriesService._providers`) — `SELECT id, enabled FROM
* photo_providers` (`.all()`, no WHERE, no ORDER BY): every provider row,
* enabled or not — the caller does its own journey-addon-gated filtering
* in JS afterward. Distinct from {@link listEnabled} above, which is a
* different legacy statement (WHERE-filtered, ordered) for a different
* caller (the admin listing); do not collapse the two (D4).
*/
async listAll(): Promise<Array<{ id: string; enabled: number }>> {
const rows = await this.find({}, { fields: ['id', 'enabled'] });
return rows.map((row) => ({ id: row.id ?? '', enabled: row.enabled ?? 0 }));
}
/** MMC1 (`MemoriesMcp.enabledProviderIds`) — `SELECT id FROM photo_providers WHERE enabled = 1`, no ORDER BY (unlike {@link listEnabled}'s admin-listing statement). */
async listEnabledIds(): Promise<string[]> {
const rows = await this.find({ enabled: 1 }, { fields: ['id'] });
return rows.map((row) => row.id).filter((id): id is string => id != null);
}
/** MMC2 (`MemoriesMcp.providerRefusal`) — `SELECT enabled FROM photo_providers WHERE id = ?`. */
async findEnabled(id: string): Promise<{ enabled: number | null } | null> {
const row = await this.findOne({ id }, { fields: ['enabled'] });
return row ? { enabled: row.enabled ?? null } : null;
}
}
@@ -8,6 +8,73 @@ export interface TripAlbumLinkSyncRow {
passphrase: string | null;
}
/**
* `trip_album_links`'s single-table write shape (Plan 3e Task 7,
* UM7/UM10). `trip_id`/`id` are typed `number | string` — the legacy
* statements bound `tripId`/`linkId` raw (T5's raw-bind seam), including the
* primary-key `id` column in UM10's `DELETE ... WHERE id = ?`.
*/
interface TripAlbumLinksWriteKyselyDB {
trip_album_links: {
id: number | string;
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
passphrase: string | null;
};
}
/**
* UM7's insert-only shape — `id`/`sync_enabled`/`last_synced_at`/`created_at`
* are autoincrement/defaulted and omitted from `.values()` (the legacy
* statement's own column list omits them too), so a SEPARATE interface from
* {@link TripAlbumLinksWriteKyselyDB} (the `FileLinksWriteKyselyDB` /
* `TripPhotosInsertKyselyDB` precedent) — that one declares `id` as
* required, which `.insertInto()` would then also require in `.values()`.
*/
interface TripAlbumLinksInsertKyselyDB {
trip_album_links: {
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
passphrase: string | null;
};
}
/** UM3's joined projection (`UnifiedMemoriesService.listTripAlbumLinks`) — own Kysely shape, one purpose-shaped interface per statement. */
interface TripAlbumLinksListKyselyDB {
trip_album_links: {
id: number;
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
sync_enabled: number;
last_synced_at: string | null;
created_at: string | null;
};
users: { id: number; username: string | null };
}
/** UM3's row shape — `tal.*, u.username`. */
export interface TripAlbumLinkListRow {
id: number;
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
sync_enabled: number;
last_synced_at: string | null;
created_at: string | null;
username: string | null;
}
export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {
/**
* MA7 (`getAlbumIdFromLink`) / MA8 (`getAlbumLinkForSync`) — same
@@ -36,4 +103,63 @@ export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { last_synced_at: currentTimestamp(platform) });
}
/**
* UM7 (`UnifiedMemoriesService.createTripAlbumLink`) — `INSERT OR IGNORE
* INTO trip_album_links (trip_id, user_id, provider, album_id, album_name,
* passphrase) VALUES (?, ?, ?, ?, ?, ?)`. `(trip, user, provider,
* album_id)` is the entity's real `uniques` constraint, so `ON CONFLICT
* (trip_id, user_id, provider, album_id) DO NOTHING` is the exact SQLite
* equivalent of the legacy `OR IGNORE`. Returns whether a row was actually
* inserted (legacy `result.changes === 0` drove the "already linked" 409).
*/
async insertIgnore(row: { trip_id: number | string; user_id: number; provider: string; album_id: string; album_name: string; passphrase: string | null }): Promise<boolean> {
const result = await this.kysely<TripAlbumLinksInsertKyselyDB>()
.insertInto('trip_album_links')
.values({ trip_id: row.trip_id, user_id: row.user_id, provider: row.provider, album_id: row.album_id, album_name: row.album_name, passphrase: row.passphrase })
.onConflict((oc) => oc.columns(['trip_id', 'user_id', 'provider', 'album_id']).doNothing())
.executeTakeFirst();
return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n;
}
/** UM10 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?`. */
async deleteScoped(id: number | string, trip_id: number | string, user_id: number): Promise<void> {
await this.kysely<TripAlbumLinksWriteKyselyDB>()
.deleteFrom('trip_album_links')
.where('id', '=', id)
.where('trip_id', '=', trip_id)
.where('user_id', '=', user_id)
.execute();
}
/**
* UM3 (`UnifiedMemoriesService.listTripAlbumLinks`) — `SELECT tal.id,
* tal.trip_id, tal.user_id, tal.provider, tal.album_id, tal.album_name,
* tal.sync_enabled, tal.last_synced_at, tal.created_at, u.username FROM
* trip_album_links tal JOIN users u WHERE tal.trip_id = ? AND tal.provider
* IN (dynamic) ORDER BY tal.created_at ASC`.
*/
async listForTrip(trip_id: number | string, enabled_providers: string[]): Promise<TripAlbumLinkListRow[]> {
if (enabled_providers.length === 0) return [];
const rows = await this.kysely<TripAlbumLinksListKyselyDB>()
.selectFrom('trip_album_links as tal')
.innerJoin('users as u', 'u.id', 'tal.user_id')
.select([
'tal.id as id',
'tal.trip_id as trip_id',
'tal.user_id as user_id',
'tal.provider as provider',
'tal.album_id as album_id',
'tal.album_name as album_name',
'tal.sync_enabled as sync_enabled',
'tal.last_synced_at as last_synced_at',
'tal.created_at as created_at',
'u.username as username',
])
.where('tal.trip_id', '=', trip_id)
.where('tal.provider', 'in', enabled_providers)
.orderBy('tal.created_at', 'asc')
.execute();
return rows;
}
}
@@ -1,6 +1,68 @@
import type { TripPhotos } from '../entities/TripPhotos.entity';
import { TrekRepository } from './_shared/trek-repository';
/**
* `trip_photos`'s single-table write/lookup shape (Plan 3e Task 7,
* UM4/UM5/UM6/UM8/UM9). `trip_id`/`album_link_id` are typed `number |
* string` — the legacy statements bound `tripId`/`albumLinkId` raw, straight
* from the route param, with no `Number()`/`toRowId` conversion (the same
* T5 raw-bind seam `TripsRepository.findForViewer`'s docstring documents),
* so this Kysely shape has to accept both to bind the identical value.
*/
interface TripPhotosKyselyDB {
trip_photos: {
id: number;
trip_id: number | string;
user_id: number;
photo_id: number;
shared: number;
album_link_id: number | string | null;
added_at: string | null;
};
}
/**
* UM4's insert-only shape — `id`/`added_at` are autoincrement/`DEFAULT
* CURRENT_TIMESTAMP` and omitted from `.values()` (the legacy statement's
* own column list omits them too), so Kysely's `InsertObject` needs a
* SEPARATE interface that doesn't declare them (the `FileLinksWriteKyselyDB`
* precedent) — {@link TripPhotosKyselyDB} declares every column, so passing
* it to `.insertInto()` would require `id`/`added_at` in every `.values()`.
*/
interface TripPhotosInsertKyselyDB {
trip_photos: {
trip_id: number | string;
user_id: number;
photo_id: number;
shared: number;
album_link_id: number | string | null;
};
}
/**
* UM2's joined projection (`UnifiedMemoriesService.listTripPhotos`) — three
* tables, so its own Kysely shape rather than folding onto
* {@link TripPhotosKyselyDB} (one purpose-shaped interface per statement,
* the `FileLinksRepository` precedent).
*/
interface TripPhotosListKyselyDB {
trip_photos: { trip_id: number | string; user_id: number; photo_id: number; shared: number; added_at: string | null };
trek_photos: { id: number; asset_id: string | null; provider: string };
users: { id: number; username: string | null; avatar: string | null };
}
/** UM2's row shape — `tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar`. */
export interface TripPhotoListRow {
photo_id: number;
asset_id: string | null;
provider: string;
user_id: number;
shared: number;
added_at: string | null;
username: string | null;
avatar: string | null;
}
export class TripPhotosRepository extends TrekRepository<TripPhotos> {
/**
* PH10's non-journey half — `SELECT 1 FROM trip_photos WHERE photo_id = ?
@@ -51,4 +113,99 @@ export class TripPhotosRepository extends TrekRepository<TripPhotos> {
const rows = await this.find({ photo: photo_id, shared: 1 });
return rows.map((row) => row.trip_id);
}
/**
* UM4 (`UnifiedMemoriesService._addTripPhoto`) — `INSERT OR IGNORE INTO
* trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES
* (?, ?, ?, ?, ?)`. `(trip, user, photo)` is the entity's real `uniques`
* constraint (this file's `TripPhotosSchema`), so `ON CONFLICT
* (trip_id, user_id, photo_id) DO NOTHING` is the exact SQLite equivalent
* of the legacy `OR IGNORE`. Returns whether a row was actually inserted
* (`result.changes > 0` in the legacy code) — `numInsertedOrUpdatedRows`
* is Kysely's dialect-general affected-row count, 0 when the conflict
* target already existed and the insert was ignored.
*/
async insertIgnore(row: { trip_id: number | string; user_id: number; photo_id: number; shared: number; album_link_id: number | string | null }): Promise<boolean> {
const result = await this.kysely<TripPhotosInsertKyselyDB>()
.insertInto('trip_photos')
.values({ trip_id: row.trip_id, user_id: row.user_id, photo_id: row.photo_id, shared: row.shared, album_link_id: row.album_link_id })
.onConflict((oc) => oc.columns(['trip_id', 'user_id', 'photo_id']).doNothing())
.executeTakeFirst();
return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n;
}
/** UM5 (`UnifiedMemoriesService.setTripPhotoSharing`) — `UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */
async setShared(trip_id: number | string, user_id: number, photo_id: number, shared: number): Promise<void> {
await this.kysely<TripPhotosKyselyDB>()
.updateTable('trip_photos')
.set({ shared })
.where('trip_id', '=', trip_id)
.where('user_id', '=', user_id)
.where('photo_id', '=', photo_id)
.execute();
}
/** UM6 (`UnifiedMemoriesService.removeTripPhoto`) — `DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */
async deleteForUserPhoto(trip_id: number | string, user_id: number, photo_id: number): Promise<void> {
await this.kysely<TripPhotosKyselyDB>()
.deleteFrom('trip_photos')
.where('trip_id', '=', trip_id)
.where('user_id', '=', user_id)
.where('photo_id', '=', photo_id)
.execute();
}
/** UM8 (`UnifiedMemoriesService.removeAlbumLink`'s pre-tx orphan-candidate list) — `SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */
async listPhotoIdsForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise<number[]> {
const rows = await this.kysely<TripPhotosKyselyDB>()
.selectFrom('trip_photos')
.select(['photo_id'])
.where('trip_id', '=', trip_id)
.where('album_link_id', '=', album_link_id)
.execute();
return rows.map((r) => r.photo_id);
}
/** UM9 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */
async deleteForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise<void> {
await this.kysely<TripPhotosKyselyDB>()
.deleteFrom('trip_photos')
.where('trip_id', '=', trip_id)
.where('album_link_id', '=', album_link_id)
.execute();
}
/**
* UM2 (`UnifiedMemoriesService.listTripPhotos`) — `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 JOIN
* users u WHERE tp.trip_id = ? AND (tp.user_id = ? OR tp.shared = 1) AND
* tkp.provider IN (dynamic) ORDER BY tp.added_at ASC`. The caller already
* guards the empty-`enabled_providers` case (`ServiceResult` 400 before
* this is ever reached); the early return here is defensive, matching an
* `IN ()` that can never match any row.
*/
async listForTrip(trip_id: number | string, user_id: number, enabled_providers: string[]): Promise<TripPhotoListRow[]> {
if (enabled_providers.length === 0) return [];
const rows = await this.kysely<TripPhotosListKyselyDB>()
.selectFrom('trip_photos as tp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'tp.photo_id')
.innerJoin('users as u', 'u.id', 'tp.user_id')
.select([
'tp.photo_id as photo_id',
'tkp.asset_id as asset_id',
'tkp.provider as provider',
'tp.user_id as user_id',
'tp.shared as shared',
'tp.added_at as added_at',
'u.username as username',
'u.avatar as avatar',
])
.where('tp.trip_id', '=', trip_id)
.where((eb) => eb.or([eb('tp.user_id', '=', user_id), eb('tp.shared', '=', 1)]))
.where('tkp.provider', 'in', enabled_providers)
.orderBy('tp.added_at', 'asc')
.execute();
return rows;
}
}
@@ -1114,4 +1114,142 @@ export class UsersRepository extends TrekRepository<Users> {
const row = await this.findOne({ feed_token: token }, { fields: ['id', 'username'] });
return row ? { id: row.id, username: row.username } : undefined;
}
// ---------------------------------------------------------------------
// Plan 3e Task 7 (memories' provider half) — additive: the Immich/Synology
// encrypted-credential statements (IM1-5/SY1-8, inventory §7d, R6). Every
// value stays exactly what the SERVICE (`ImmichService`/`SynologyService`)
// hands this repository — encrypted, plaintext or null, at the same point
// in the pipeline the raw SQL saw it. This repository never calls
// `encrypt_api_key`/`decrypt_api_key`/`maybe_encrypt_api_key` itself.
// ---------------------------------------------------------------------
/** IM1 (`ImmichService.getImmichCredentials`) — `SELECT immich_url, immich_api_key FROM users WHERE id = ?`. */
async getImmichCredentials(id: number): Promise<{ immich_url: string | null; immich_api_key: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['immich_url', 'immich_api_key'] });
return row ? { immich_url: row.immich_url ?? null, immich_api_key: row.immich_api_key ?? null } : null;
}
/** IM2 (`ImmichService.getConnectionSettings`'s prefs read) — `SELECT immich_auto_upload FROM users WHERE id = ?`. */
async getImmichAutoUpload(id: number): Promise<number | null> {
const row = await this.findOne({ id }, { fields: ['immich_auto_upload'] });
return row?.immich_auto_upload ?? null;
}
/** IM3 (`ImmichService.setImmichAutoUpload`) — `UPDATE users SET immich_auto_upload = ? WHERE id = ?`. */
async setImmichAutoUpload(id: number, enabled: number): Promise<void> {
await this.nativeUpdate({ id }, { immich_auto_upload: enabled });
}
/**
* IM4/IM5 (`ImmichService.saveImmichSettings`'s two branches) — `UPDATE
* users SET immich_url = ?, immich_api_key = ? WHERE id = ?`, byte-identical
* statement at both legacy sites (the URL branch's trimmed value, the else
* branch's `null`) — one method, per R6.
*/
async setImmichSettings(id: number, immich_url: string | null, immich_api_key: string | null): Promise<void> {
await this.nativeUpdate({ id }, { immich_url, immich_api_key });
}
/**
* SY1 (`SynologyService._readSynologyUser`) — `SELECT synology_url,
* synology_username, synology_password, synology_sid, synology_did,
* synology_skip_ssl FROM users WHERE id = ?`, column-filtered in JS by the
* legacy code. This method selects only the requested columns (MikroORM's
* typed `fields`) rather than all six and filtering after — the RESULT is
* identical (only the requested keys populated, matching the legacy
* `filtered` object exactly; an unrequested key is `undefined`, same as
* the legacy object never having had it set). `null` means no such user
* row (SY1's "User not found" branch).
*
* Explicit per-column `if` guards, not a generic `Pick<UserRow, K>` +
* indexed-assignment loop: MikroORM's `fields` option types against
* `AutoPath<Users, K, ...>`, which does not resolve for a free type
* parameter `K` the way it does for the concrete literal union used here
* (the same reason `getApiKeyColumn` above stays non-generic); an
* indexed-assignment loop over a union key has the identical problem on
* the write side. Six `if`s reads worse but needs neither a generic nor a
* cast.
*/
async getSynologyFields(id: number, columns: SynologyUserColumn[]): Promise<SynologyFieldsRow | null> {
const row = await this.findOne({ id }, { fields: columns });
if (!row) return null;
const result: SynologyFieldsRow = {};
if (columns.includes('synology_url')) result.synology_url = row.synology_url ?? null;
if (columns.includes('synology_username')) result.synology_username = row.synology_username ?? null;
if (columns.includes('synology_password')) result.synology_password = row.synology_password ?? null;
if (columns.includes('synology_sid')) result.synology_sid = row.synology_sid ?? null;
if (columns.includes('synology_did')) result.synology_did = row.synology_did ?? null;
if (columns.includes('synology_skip_ssl')) result.synology_skip_ssl = row.synology_skip_ssl;
return result;
}
/** SY2 (`SynologyService._clearSynologySID`) — `UPDATE users SET synology_sid = NULL WHERE id = ?`. */
async clearSynologySID(id: number): Promise<void> {
await this.nativeUpdate({ id }, { synology_sid: null });
}
/** SY3 (`SynologyService._clearSynologySession`) — `UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?`. */
async clearSynologySession(id: number): Promise<void> {
await this.nativeUpdate({ id }, { synology_sid: null, synology_did: null });
}
/**
* SY4/SY7 (`SynologyService._getSynologySession`'s session-refresh write,
* `testSynologyConnection`'s sid persist) — byte-identical `UPDATE users
* SET synology_sid = ? WHERE id = ?` at both legacy sites, one method (D4).
*/
async setSynologySid(id: number, synology_sid: string): Promise<void> {
await this.nativeUpdate({ id }, { synology_sid });
}
/** SY5 (`SynologyService.updateSynologySettings`) — `UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?`. */
async setSynologySettings(id: number, synology_url: string, synology_username: string, synology_password: string | null, synology_skip_ssl: number): Promise<void> {
await this.nativeUpdate({ id }, { synology_url, synology_username, synology_password, synology_skip_ssl });
}
/** SY6 (`SynologyService.getSynologyStatus`) — `SELECT synology_username FROM users WHERE id = ?`. */
async getSynologyUsername(id: number): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['synology_username'] });
return row?.synology_username ?? null;
}
/** SY8 (`SynologyService.testSynologyConnection`'s did persist) — `UPDATE users SET synology_did = ? WHERE id = ?`. */
async setSynologyDid(id: number, synology_did: string): Promise<void> {
await this.nativeUpdate({ id }, { synology_did });
}
// ---------------------------------------------------------------------
// UM11 — UnifiedMemoriesService._notifySharedTripPhotos
// ---------------------------------------------------------------------
/** UM11 — `SELECT username, email FROM users WHERE id = ?`, the shared-photos notification's actor lookup. */
async findUsernameEmail(id: number): Promise<{ username: string; email: string } | undefined> {
const row = await this.findOne({ id }, { fields: ['username', 'email'] });
return row ? { username: row.username, email: row.email } : undefined;
}
}
/**
* The six Synology credential/session columns `_readSynologyUser` reads
* selectively — a closed union, not a dynamic column string, matching
* `InstanceApiKeyName`'s reasoning above.
*/
export type SynologyUserColumn = 'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl';
/**
* {@link UsersRepository.getSynologyFields}'s return shape — structurally
* identical to `synology.service.ts`'s own `SynologyUserRecord` (every field
* optional, so an unrequested column is simply absent), deliberately
* defined here rather than imported from the service: a repository does not
* depend on a domain service's types, and the two stay structurally
* assignable without either side casting.
*/
export interface SynologyFieldsRow {
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;
}
+11 -17
View File
@@ -1,14 +1,16 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
import { AuditService } from '../audit/audit.service';
import { StorageService } from '../storage/storage.service';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import { fail, handleServiceResult, isWithinLocalDayRange, pipeAsset, shiftCalendarDay, sortAssetsByTakenAtDesc, type Selection } from './memories.helpers';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
const ALBUM_PAGE_SIZE = 1000;
const ALBUM_MAX_PAGES = 20;
@@ -34,18 +36,18 @@ const SEARCH_MAX_RAW_PAGES = 20;
@Injectable()
export class ImmichService {
constructor(
private readonly db: DatabaseService,
private readonly audit: AuditService,
private readonly access: MemoriesAccessService,
private readonly storage: StorageService,
@InjectRepository(Users) private readonly users: UsersRepository,
) {}
async getImmichCredentials(userId: number) {
const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
const user = await this.users.getImmichCredentials(userId);
if (!user?.immich_url || !user?.immich_api_key) return null;
const apiKey = decrypt_api_key(user.immich_api_key);
if (!apiKey) return null;
return { immich_url: user.immich_url as string, immich_api_key: apiKey };
return { immich_url: user.immich_url, immich_api_key: apiKey };
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
@@ -73,16 +75,16 @@ export class ImmichService {
async getConnectionSettings(userId: number) {
const creds = await this.getImmichCredentials(userId);
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
const autoUpload = await this.users.getImmichAutoUpload(userId);
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
auto_upload: !!(prefs?.immich_auto_upload),
auto_upload: !!autoUpload,
};
}
async setImmichAutoUpload(userId: number, enabled: boolean): Promise<void> {
this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
await this.users.setImmichAutoUpload(userId, enabled ? 1 : 0);
}
async saveImmichSettings(
@@ -99,11 +101,7 @@ export class ImmichService {
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
userId
);
await this.users.setImmichSettings(userId, immichUrl.trim(), maybe_encrypt_api_key(immichApiKey));
if (ssrf.isPrivate) {
await this.audit.writeAudit({
userId,
@@ -117,11 +115,7 @@ export class ImmichService {
};
}
} else {
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
);
await this.users.setImmichSettings(userId, null, maybe_encrypt_api_key(immichApiKey));
}
return { success: true };
}
+6 -4
View File
@@ -3,11 +3,13 @@ import {
errorResult, ok, type McpContext,
} from '../../nest-mcp';
import { z } from 'zod';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import type { PhotoProvidersRepository } from '../../db/repositories/PhotoProviders.repository';
/**
* The photo backends TREK can talk to, named rather than taken as a free
@@ -65,8 +67,8 @@ export class MemoriesMcp {
constructor(
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly db: DatabaseService,
private readonly addons: AddonsService,
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
) {}
/**
@@ -75,7 +77,7 @@ export class MemoriesMcp {
*/
async enabledProviderIds(): Promise<string[]> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return [];
return this.db.all<{ id: string }>('SELECT id FROM photo_providers WHERE enabled = 1').map((row) => row.id);
return await this.photoProviders.listEnabledIds();
}
/**
@@ -85,7 +87,7 @@ export class MemoriesMcp {
* themselves never checked it, so this only ever narrows what REST allows.
*/
private async providerRefusal(provider: ProviderId) {
const row = this.db.get<{ enabled: number }>('SELECT enabled FROM photo_providers WHERE id = ?', provider);
const row = await this.photoProviders.findEnabled(provider);
if (!row) return errorResult(`Provider: "${provider}" is not supported`);
if (row.enabled !== 1 || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)))
return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`);
+8 -2
View File
@@ -31,6 +31,8 @@ import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import { Users } from '../../db/entities/Users.entity';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
@@ -57,12 +59,16 @@ import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity'
* (owned elsewhere, reached the same way every cross-domain module in this
* program reaches another domain's entity) for `MemoriesAccessService`'s
* `@InjectRepository` constructor params, and `TrekPhotoCacheMetaRepository`
* for `TrekPhotoCacheService`'s (Plan 3e Task 6).
* for `TrekPhotoCacheService`'s (Plan 3e Task 6). `PhotoProviders` (owned by
* this module) and `Users` (owned elsewhere, reached the same way `Trips`
* is above) are added for `UnifiedMemoriesService`/`ImmichService`/
* `SynologyService`/`MemoriesMcp`'s `@InjectRepository` constructor params
* (Plan 3e Task 7).
*/
@Module({
imports: [
NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule,
MikroOrmModule.forFeature([TripPhotos, TrekPhotos, TripAlbumLinks, Trips, TrekPhotoCacheMeta]),
MikroOrmModule.forFeature([TripPhotos, TrekPhotos, TripAlbumLinks, Trips, TrekPhotoCacheMeta, PhotoProviders, Users]),
],
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [
+16 -19
View File
@@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository, SynologyUserColumn } from '../../db/repositories/Users.repository';
import {
dayStartEpochSeconds,
fail,
@@ -129,24 +131,19 @@ interface SynologyPhotoItem {
@Injectable()
export class SynologyService {
constructor(
private readonly db: DatabaseService,
private readonly access: MemoriesAccessService,
private readonly notifications: NotificationsService,
@InjectRepository(Users) private readonly users: UsersRepository,
) {}
private async _readSynologyUser(userId: number, columns: string[]): Promise<ServiceResult<SynologyUserRecord>> {
private async _readSynologyUser(userId: number, columns: SynologyUserColumn[]): Promise<ServiceResult<SynologyUserRecord>> {
try {
const row = this.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;
const filtered = await this.users.getSynologyFields(userId, columns);
if (!row) {
if (!filtered) {
return fail('User not found', 404);
}
const filtered: SynologyUserRecord = {};
for (const column of columns) {
filtered[column] = row[column];
}
return success(filtered);
} catch {
return fail('Failed to read Synology user data', 500);
@@ -306,11 +303,11 @@ export class SynologyService {
private async _clearSynologySID(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
await this.users.clearSynologySID(userId);
}
private async _clearSynologySession(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
await this.users.clearSynologySession(userId);
}
private _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
@@ -349,7 +346,7 @@ export class SynologyService {
return resp as ServiceResult<string>;
}
this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
await this.users.setSynologySid(userId, encrypt_api_key(resp.data.sid));
return success(resp.data.sid);
}
@@ -403,12 +400,12 @@ export class SynologyService {
}
try {
this.db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
await this.users.setSynologySettings(
userId,
synologyUrl,
synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
synologySkipSsl ? 1 : 0,
userId,
);
} catch {
return fail('Failed to update Synology settings', 500);
@@ -422,8 +419,8 @@ export class SynologyService {
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 = this.db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } });
const synologyUsername = await this.users.getSynologyUsername(userId);
return success({ connected: true, user: { name: synologyUsername || 'unknown user' } });
} catch (err: unknown) {
return success({ connected: true, user: { name: 'unknown user' } });
}
@@ -443,9 +440,9 @@ export class SynologyService {
// Persist the session so the OTP code is not required again on save.
// The did (device token) allows future re-logins without OTP.
this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
await this.users.setSynologySid(userId, encrypt_api_key(resp.data.sid));
if (resp.data.did) {
this.db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
await this.users.setSynologyDid(userId, encrypt_api_key(resp.data.did));
}
return success({ connected: true, user: { name: synologyUsername } });
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ADDON_IDS } from '../../addons';
import { broadcast } from '../../websocket';
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
@@ -18,6 +18,17 @@ import {
} from './memories.helpers';
import { NotificationsService } from '../notifications/notifications.service';
import { UnitOfWork } from '../database/unit-of-work';
import { RealtimeService } from '../realtime/realtime.service';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import type { PhotoProvidersRepository } from '../../db/repositories/PhotoProviders.repository';
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository';
import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity';
import type { TripAlbumLinksRepository } from '../../db/repositories/TripAlbumLinks.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
/**
* The provider-agnostic trip photo surface: which photos a trip shows, which
@@ -36,6 +47,12 @@ export class UnifiedMemoriesService {
private readonly notifications: NotificationsService,
private readonly addons: AddonsService,
private readonly uow: UnitOfWork,
private readonly realtime: RealtimeService,
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
@InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository,
@InjectRepository(TripAlbumLinks) private readonly tripAlbumLinks: TripAlbumLinksRepository,
@InjectRepository(Users) private readonly users: UsersRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
private async _providers(): Promise<Array<{id: string; enabled: boolean}>> {
@@ -43,7 +60,7 @@ export class UnifiedMemoriesService {
// surface lives inside journeys. Covers rows left enabled from before
// updateAddon cascaded the journey disable.
const journeyOn = await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>;
const rows = await this.photoProviders.listAll();
return rows.map(r => ({ id: r.id, enabled: journeyOn && r.enabled === 1 }));
}
@@ -76,17 +93,7 @@ export class UnifiedMemoriesService {
return fail('No photo providers enabled', 400);
}
const photos = this.db.prepare(`
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 tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tp.added_at ASC
`).all(tripId, userId, ...enabledProviders);
const photos = await this.tripPhotos.listForTrip(tripId, userId, enabledProviders);
return success(photos);
} catch (error) {
@@ -108,23 +115,7 @@ export class UnifiedMemoriesService {
}
try {
const links = this.db.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
AND tal.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tal.created_at ASC
`).all(tripId, ...enabledProviders);
const links = await this.tripAlbumLinks.listForTrip(tripId, enabledProviders);
return success(links);
} catch (error) {
@@ -142,10 +133,14 @@ export class UnifiedMemoriesService {
}
try {
const photoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase);
const result = this.db.prepare(
'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);
const added = await this.tripPhotos.insertIgnore({
trip_id: tripId,
user_id: userId,
photo_id: photoId,
shared: shared ? 1 : 0,
album_link_id: albumLinkId || null,
});
return success(added);
}
catch (error) {
return mapDbError(error, 'Failed to add photo to trip');
@@ -189,7 +184,7 @@ export class UnifiedMemoriesService {
}
await this._notifySharedTripPhotos(tripId, userId, added);
broadcast(tripId, 'memories:updated', { userId }, sid);
this.realtime.broadcast(tripId, 'memories:updated', { userId }, sid);
return success({ added, shared });
}
@@ -207,16 +202,10 @@ export class UnifiedMemoriesService {
}
try {
this.db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(shared ? 1 : 0, tripId, userId, photoId);
await this.tripPhotos.setShared(tripId, userId, photoId, shared ? 1 : 0);
await this._notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid);
this.realtime.broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to update photo sharing');
@@ -235,15 +224,10 @@ export class UnifiedMemoriesService {
}
try {
this.db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(tripId, userId, photoId);
await this.tripPhotos.deleteForUserPhoto(tripId, userId, photoId);
await this.photos.deleteIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
this.realtime.broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
@@ -279,11 +263,16 @@ export class UnifiedMemoriesService {
try {
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
const result = this.db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
const added = await this.tripAlbumLinks.insertIgnore({
trip_id: tripId,
user_id: userId,
provider,
album_id: albumId,
album_name: albumName,
passphrase: encryptedPassphrase,
});
if (result.changes === 0) {
if (!added) {
return fail('Album already linked', 409);
}
@@ -300,17 +289,14 @@ export class UnifiedMemoriesService {
}
try {
const linkedPhotos = this.db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
const linkedPhotoIds = await this.tripPhotos.listPhotoIdsForAlbumLink(tripId, linkId);
await this.uow.transactional(async () => {
this.db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
this.db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
await this.tripPhotos.deleteForAlbumLink(tripId, linkId);
await this.tripAlbumLinks.deleteScoped(linkId, tripId, userId);
});
for (const { photo_id } of linkedPhotos) {
for (const photo_id of linkedPhotoIds) {
await this.photos.deleteIfOrphan(photo_id);
}
@@ -332,12 +318,11 @@ export class UnifiedMemoriesService {
if (added <= 0) return success(undefined);
try {
const actorRow = this.db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null };
const actorRow = await this.users.findUsernameEmail(actorUserId);
const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
const tripTitle = await this.trips.getTitle(tripId);
this.notifications.send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
this.notifications.send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripTitle || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
return success(undefined);
} catch {
return fail('Failed to send notifications', 500);