mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(memories): unified, immich and synology photo sync through repositories
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -68,6 +68,7 @@ import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
|
||||
import { TripAlbumLinks } from '../../src/db/entities/TripAlbumLinks.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { TrekPhotoCacheMeta } from '../../src/db/entities/TrekPhotoCacheMeta.entity';
|
||||
import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity';
|
||||
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
|
||||
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
|
||||
import { WebauthnConfigService } from '../../src/nest/auth/webauthn-config.service';
|
||||
@@ -132,6 +133,7 @@ import {
|
||||
createTestUsersRepo,
|
||||
} from './test-uow';
|
||||
import { createTestOrm } from './test-orm';
|
||||
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
@@ -182,7 +184,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo),
|
||||
new MailerService(dbService),
|
||||
new EphemeralTokenService(),
|
||||
new AllowedFileTypesService(dbService), await createTestUnitOfWork(dbService.connection),
|
||||
new AllowedFileTypesService(appSettings), await createTestUnitOfWork(dbService.connection),
|
||||
appSettings, usersRepo, inviteTokensRepo, mcpTokensRepoForAuth, oauthTokensRepo, webauthnCredentialsRepoForAuth, passwordResetTokensRepo,
|
||||
);
|
||||
const queryHelpersService = new QueryHelpersService(await createTestTagsRepo(dbService.connection), await createTestPlaceRatingsRepo(dbService.connection), await createTestAssignmentParticipantsRepo(dbService.connection));
|
||||
@@ -265,10 +267,24 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
await createTestUnitOfWork(dbService.connection),
|
||||
mcpOrm.em,
|
||||
);
|
||||
// Plan 3e Task 1 (files): FilesService now also takes uow + the repositories
|
||||
// its R2 transactions and R12 cross-object trip-scoping guard need. Built
|
||||
// once and shared by both consumers below (readModelService, FilesMcp)
|
||||
// rather than reconstructed twice with the same seven-repository tail.
|
||||
const filesService = new FilesService(
|
||||
dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em,
|
||||
await createTestUnitOfWork(dbService.connection),
|
||||
await createTestTripFilesRepo(dbService.connection),
|
||||
await createTestFileLinksRepo(dbService.connection),
|
||||
await createTestReservationsRepo(dbService.connection),
|
||||
await createTestPlacesRepo(dbService.connection),
|
||||
await createTestDayAssignmentsRepo(dbService.connection),
|
||||
await createTestBudgetItemsRepo(dbService.connection),
|
||||
);
|
||||
const readModelService = new TripReadModelService(
|
||||
await createTestTripsRepo(dbService.connection), membersService, daysService, accommodationsService, budgetService,
|
||||
packingService, reservationsService, collabService, placesService, todoService,
|
||||
new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em),
|
||||
filesService,
|
||||
);
|
||||
const calendarService = new CalendarService(
|
||||
reservationsService,
|
||||
@@ -286,8 +302,8 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
// stubbed: an empty provider registry would make the backfill answer "unknown
|
||||
// provider" for every id and hide a wiring mistake behind a caught error.
|
||||
const memoriesAccess = new MemoriesAccessService(dbService, mcpOrm.repo(TripPhotos), mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripAlbumLinks), mcpOrm.repo(Trips));
|
||||
const immichService = new ImmichService(dbService, new AuditService(auditLogRepo, usersRepo), memoriesAccess, generalStorage);
|
||||
const synologyService = new SynologyService(dbService, memoriesAccess, notificationsStub());
|
||||
const immichService = new ImmichService(new AuditService(auditLogRepo, usersRepo), memoriesAccess, generalStorage, usersRepo);
|
||||
const synologyService = new SynologyService(memoriesAccess, notificationsStub(), usersRepo);
|
||||
const trekPhotos = new TrekPhotoRegistrationService(mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripPhotos), dbService);
|
||||
const captureBackfill = new PhotoCaptureBackfillService(new PhotoResolverService(trekPhotos, new ThumbnailService(addonsService, generalStorage, mcpOrm.repo(TrekPhotos)), new TrekPhotoCacheService(mcpOrm.repo(TrekPhotoCacheMeta), generalStorage), new PhotoProviderRegistry([new ImmichPhotoProvider(immichService), new SynologyPhotoProvider(synologyService)]), generalStorage), trekPhotos, generalStorage);
|
||||
return createTestRegistry(
|
||||
@@ -318,7 +334,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
authService,
|
||||
addonsService,
|
||||
),
|
||||
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em), authService, guards),
|
||||
new FilesMcp(filesService, authService, guards),
|
||||
new AccommodationsMcp(accommodationsService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)),
|
||||
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
|
||||
new CollabMcp(collabService, authService, addonsService, guards),
|
||||
@@ -335,7 +351,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
new TransitMcp(new TransitService(new GoogleTransitProvider(dbService, appSettings, usersRepo)), daysService, reservationsService, dbService, authService, guards),
|
||||
new AtlasMcp(new AtlasService(dbService, await createTestUnitOfWork(dbService.connection)), addonsService, authService),
|
||||
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(await createTestUnitOfWork(dbService.connection), appSettings, await createTestSettingsRepo(dbService.connection))), addonsService, authService, captureBackfill),
|
||||
new MemoriesMcp(immichService, synologyService, dbService, addonsService),
|
||||
new MemoriesMcp(immichService, synologyService, addonsService, mcpOrm.repo(PhotoProviders)),
|
||||
new NotificationsMcp(await makeNotificationsService(dbService, realtimeService), authService),
|
||||
new AirtrailMcp(new AirtrailService(dbService, new AuditService(auditLogRepo, usersRepo), new AirtrailClient()), addonsService),
|
||||
new ReservationImportMcp(new AirtrailImportService(dbService, realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(dbService, new AuditService(auditLogRepo, usersRepo), new AirtrailClient())), dbService, authService, guards, addonsService),
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
|
||||
|
||||
/**
|
||||
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
|
||||
@@ -112,7 +113,18 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
const queryHelpers = new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection));
|
||||
const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection));
|
||||
const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
|
||||
const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage, (await sharedTestOrm(dbs.connection)).em);
|
||||
// Plan 3e Task 1 (files): FilesService now also takes uow + the repositories
|
||||
// its R2 transactions and R12 cross-object trip-scoping guard need.
|
||||
const files = new FilesService(
|
||||
dbs, permissions, realtime, new EphemeralTokenService(), generalStorage, (await sharedTestOrm(dbs.connection)).em,
|
||||
await createTestUnitOfWork(dbs.connection),
|
||||
await createTestTripFilesRepo(dbs.connection),
|
||||
await createTestFileLinksRepo(dbs.connection),
|
||||
await createTestReservationsRepo(dbs.connection),
|
||||
await createTestPlacesRepo(dbs.connection),
|
||||
await createTestDayAssignmentsRepo(dbs.connection),
|
||||
await createTestBudgetItemsRepo(dbs.connection),
|
||||
);
|
||||
const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbs.connection));
|
||||
const vacay = new VacayService(dbs, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
|
||||
const days = new DaysService(
|
||||
@@ -182,7 +194,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
new TodoRpc(todos, realtime, guards),
|
||||
new DayNotesRpc(dayNotes, realtime, guards),
|
||||
new PackingRpc(packing, realtime, guards),
|
||||
new FilesRpc(files, realtime, dbs, guards, generalStorage),
|
||||
new FilesRpc(files, realtime, usersRepo, guards, generalStorage),
|
||||
new PlacesRpc(places, journey, realtime, guards),
|
||||
new DaysRpc(days, realtime, guards),
|
||||
new AccommodationsRpc(accommodations, realtime, guards),
|
||||
|
||||
@@ -25,11 +25,14 @@ vi.mock('../../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) }));
|
||||
const { decryptMock, maybeEncryptMock } = vi.hoisted(() => ({
|
||||
decryptMock: vi.fn((v: string) => v),
|
||||
maybeEncryptMock: vi.fn((v: string) => v),
|
||||
}));
|
||||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: decryptMock,
|
||||
encrypt_api_key: (v: string) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
maybe_encrypt_api_key: maybeEncryptMock,
|
||||
}));
|
||||
|
||||
const { safeFetch, checkSsrf } = vi.hoisted(() => ({ safeFetch: vi.fn(), checkSsrf: vi.fn() }));
|
||||
@@ -42,19 +45,18 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { ImmichService } from '../../../src/nest/memories/immich.service';
|
||||
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { createTestUsersRepo } from '../../helpers/test-uow';
|
||||
|
||||
const audit = { writeAudit: vi.fn() };
|
||||
const access = { getAlbumIdFromLink: vi.fn() };
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const journeyFx = makeStorageFixture('journey/');
|
||||
const svc = new ImmichService(dbs, audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage);
|
||||
let svc: ImmichService;
|
||||
|
||||
const USER = 1;
|
||||
|
||||
@@ -75,14 +77,17 @@ function upstream(opts: { ok?: boolean; status?: number; json?: unknown; url?: s
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
const users = await createTestUsersRepo(testDb);
|
||||
svc = new ImmichService(audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage, users);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decryptMock.mockImplementation((v: string) => v);
|
||||
maybeEncryptMock.mockImplementation((v: string) => v);
|
||||
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
||||
testDb.prepare('DELETE FROM users').run();
|
||||
seedUser(USER, 'https://immich.test', 'key-1');
|
||||
@@ -175,6 +180,26 @@ describe('saveImmichSettings', () => {
|
||||
expect(checkSsrf).not.toHaveBeenCalled();
|
||||
expect(await svc.getImmichCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
// R6 — the encrypt call still runs; this file mocks `maybe_encrypt_api_key`
|
||||
// as identity everywhere else (so cases above can assert on the plaintext
|
||||
// round-trip), so this one case swaps in the REAL crypto for the duration
|
||||
// of a single call and reads the raw column back — proving
|
||||
// `UsersRepository.setImmichSettings` never bypasses the service's encrypt
|
||||
// step. `beforeEach` restores the identity stub for every other case.
|
||||
it('IMMICH-051 (R6): the stored immich_api_key is the encrypted envelope, never the plaintext', async () => {
|
||||
const real = await vi.importActual<typeof import('../../../src/nest/common/crypto/apiKeyCrypto')>(
|
||||
'../../../src/nest/common/crypto/apiKeyCrypto',
|
||||
);
|
||||
maybeEncryptMock.mockImplementation(real.maybe_encrypt_api_key);
|
||||
|
||||
const plaintext = 'synthetic-test-immich-key-001';
|
||||
await svc.saveImmichSettings(USER, 'https://immich.test', plaintext, null);
|
||||
|
||||
const row = testDb.prepare('SELECT immich_api_key FROM users WHERE id = ?').get(USER) as { immich_api_key: string };
|
||||
expect(row.immich_api_key).not.toBe(plaintext);
|
||||
expect(row.immich_api_key.startsWith('enc:v1:')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
|
||||
@@ -27,11 +27,14 @@ vi.mock('../../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) }));
|
||||
const { decryptMock, maybeEncryptMock } = vi.hoisted(() => ({
|
||||
decryptMock: vi.fn((v: string) => v),
|
||||
maybeEncryptMock: vi.fn((v: string) => v),
|
||||
}));
|
||||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: decryptMock,
|
||||
encrypt_api_key: (v: string) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
maybe_encrypt_api_key: maybeEncryptMock,
|
||||
}));
|
||||
|
||||
const { safeFetch, checkSsrf, SsrfBlockedError } = vi.hoisted(() => {
|
||||
@@ -46,13 +49,13 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { SynologyService } from '../../../src/nest/memories/synology.service';
|
||||
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { createTestUsersRepo } from '../../helpers/test-uow';
|
||||
|
||||
const access = { getAlbumLinkForSync: vi.fn(), updateSyncTimeForAlbumLink: vi.fn() };
|
||||
const svc = new SynologyService(new DatabaseService(testDb), access as unknown as MemoriesAccessService, notificationsStub());
|
||||
let svc: SynologyService;
|
||||
|
||||
const USER = 1;
|
||||
|
||||
@@ -76,14 +79,17 @@ function httpError(status: number) {
|
||||
return { ok: false, status, headers: { get: () => null }, json: async () => ({}), arrayBuffer: async () => Buffer.from('x') };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
const users = await createTestUsersRepo(testDb);
|
||||
svc = new SynologyService(access as unknown as MemoriesAccessService, notificationsStub(), users);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decryptMock.mockImplementation((v: string) => v);
|
||||
maybeEncryptMock.mockImplementation((v: string) => v);
|
||||
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
||||
testDb.prepare('DELETE FROM users').run();
|
||||
seedUser(USER);
|
||||
@@ -223,6 +229,27 @@ describe('updateSynologySettings', () => {
|
||||
expect(row.synology_password).toBe('pw');
|
||||
expect(row.synology_url).toBe('https://nas2.test');
|
||||
});
|
||||
|
||||
// R6 — this file mocks `maybe_encrypt_api_key` as identity elsewhere (so
|
||||
// the cases above can assert on the plaintext round-trip); this one case
|
||||
// swaps in the REAL crypto for the duration of a single call and reads the
|
||||
// raw column back — proving `UsersRepository.setSynologySettings` never
|
||||
// bypasses the service's encrypt step. `beforeEach` restores the identity
|
||||
// stub for every other case.
|
||||
it('SYNO-U032 (R6): the stored synology_password is the encrypted envelope, never the plaintext', async () => {
|
||||
safeFetch.mockResolvedValue(api({ sid: 's' }));
|
||||
const real = await vi.importActual<typeof import('../../../src/nest/common/crypto/apiKeyCrypto')>(
|
||||
'../../../src/nest/common/crypto/apiKeyCrypto',
|
||||
);
|
||||
maybeEncryptMock.mockImplementation(real.maybe_encrypt_api_key);
|
||||
|
||||
const plaintext = 'synthetic-test-synology-pw-001';
|
||||
await svc.updateSynologySettings(USER, 'https://nas3.test', 'ada', plaintext);
|
||||
|
||||
const row = testDb.prepare('SELECT synology_password FROM users WHERE id = ?').get(USER) as { synology_password: string };
|
||||
expect(row.synology_password).not.toBe(plaintext);
|
||||
expect(row.synology_password.startsWith('enc:v1:')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testSynologyConnection', () => {
|
||||
|
||||
@@ -36,7 +36,6 @@ vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
@@ -51,16 +50,43 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||||
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
||||
import { TripAlbumLinks } from '../../../src/db/entities/TripAlbumLinks.entity';
|
||||
import { Trips } from '../../../src/db/entities/Trips.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
import type { ServiceResult } from '../../../src/nest/memories/memories.helpers';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { ImmichService } from '../../../src/nest/memories/immich.service';
|
||||
import type { SynologyService } from '../../../src/nest/memories/synology.service';
|
||||
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import type { TripPhotosRepository } from '../../../src/db/repositories/TripPhotos.repository';
|
||||
import type { TripAlbumLinksRepository } from '../../../src/db/repositories/TripAlbumLinks.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, sharedTestOrm, createTestUsersRepo } from '../../helpers/test-uow';
|
||||
|
||||
// The album-sync paths are the providers' half and have their own suites; these
|
||||
// cases never reach them, so stubs keep the graph small.
|
||||
const dbs = new DatabaseService(testDb);
|
||||
let svc: UnifiedMemoriesService;
|
||||
const realtimeMock = { broadcast: vi.fn() };
|
||||
let tripPhotosRepo: TripPhotosRepository;
|
||||
let tripAlbumLinksRepo: TripAlbumLinksRepository;
|
||||
let usersRepo: UsersRepository;
|
||||
|
||||
// Narrows the `ServiceResult` discriminated union without an `as any` cast —
|
||||
// used by the new (Task 7) cases below only; the pre-existing cases above
|
||||
// keep their original `(result as any)` shape unchanged.
|
||||
function expectFailure<T>(result: ServiceResult<T>): { message: string; status: number } {
|
||||
// `'error' in result`, not `result.success` — discriminant narrowing over a
|
||||
// union with a generic member (`{ success: true; data: T }`) doesn't
|
||||
// resolve here (a TS control-flow limitation on generic discriminated
|
||||
// unions); `in` narrows correctly, the same idiom `handleServiceResult`
|
||||
// (`memories.helpers.ts`) already uses.
|
||||
if (!('error' in result)) throw new Error('expected a ServiceResult failure, got success');
|
||||
return result.error;
|
||||
}
|
||||
function expectSuccess<T>(result: ServiceResult<T>): T {
|
||||
if ('error' in result) throw new Error(`expected a ServiceResult success, got: ${result.error.message}`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// Legacy free-function names forwarding to the service, so the moved cases read
|
||||
// as before. Typed forwarders rather than `.bind` aliases: a bound alias is
|
||||
@@ -86,6 +112,9 @@ beforeAll(async () => {
|
||||
const t = await sharedTestOrm(testDb);
|
||||
const real = new DatabaseService(testDb, t.em);
|
||||
vi.spyOn(dbs, 'canAccessTrip').mockImplementation((...a) => real.canAccessTrip(...a));
|
||||
tripPhotosRepo = t.repo(TripPhotos);
|
||||
tripAlbumLinksRepo = t.repo(TripAlbumLinks);
|
||||
usersRepo = await createTestUsersRepo(testDb);
|
||||
svc = new UnifiedMemoriesService(
|
||||
dbs,
|
||||
new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs),
|
||||
@@ -95,6 +124,12 @@ beforeAll(async () => {
|
||||
notificationsStub(),
|
||||
await createTestAddonsService(testDb, dbs),
|
||||
await createTestUnitOfWork(testDb),
|
||||
realtimeMock as unknown as RealtimeService,
|
||||
t.repo(PhotoProviders),
|
||||
tripPhotosRepo,
|
||||
tripAlbumLinksRepo,
|
||||
usersRepo,
|
||||
t.repo(Trips),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -104,6 +139,7 @@ beforeEach(() => {
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 1').run();
|
||||
// Providers only count as enabled under an enabled journey addon (migration 84 seeds it off).
|
||||
setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true);
|
||||
realtimeMock.broadcast.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -256,6 +292,20 @@ describe('createTripAlbumLink', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(400);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-019: UM7 insertIgnore reports the 409 "already linked" on a re-link (duplicate-ignore, not a crash)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const first = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-dup-1', 'First Name');
|
||||
expectSuccess(first);
|
||||
|
||||
const second = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-dup-1', 'Second Name');
|
||||
expect(expectFailure(second).status).toBe(409);
|
||||
|
||||
const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_album_links WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number };
|
||||
expect(rows.c).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeAlbumLink ───────────────────────────────────────────────────────────
|
||||
@@ -266,4 +316,173 @@ describe('removeAlbumLink', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-020: UM8/UM9/UM10 — the transaction removes the link and every trip_photos row it owned', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const linked = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-rm-1', 'To Remove');
|
||||
expect(linked.success).toBe(true);
|
||||
const linkRow = testDb.prepare('SELECT id FROM trip_album_links WHERE trip_id = ? AND user_id = ? AND album_id = ?').get(trip.id, user.id, 'album-rm-1') as { id: number };
|
||||
|
||||
await addTripPhotos(String(trip.id), user.id, true, [{ provider: 'immich', asset_ids: ['asset-rm-1'] }], 'sid-6', String(linkRow.id));
|
||||
const photoRow = testDb.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id) as { photo_id: number };
|
||||
expect(photoRow).toBeDefined();
|
||||
|
||||
const result = await removeAlbumLink(String(trip.id), String(linkRow.id), user.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const remainingLink = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkRow.id);
|
||||
expect(remainingLink).toBeUndefined();
|
||||
const remainingPhoto = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id);
|
||||
expect(remainingPhoto).toBeUndefined();
|
||||
// The orphaned trek_photos row is reclaimed too (TrekPhotoRegistrationService.deleteIfOrphan, PH10/PH11).
|
||||
const orphan = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoRow.photo_id);
|
||||
expect(orphan).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── UM2/UM3 parity — full-key toEqual against the legacy statement run raw ────
|
||||
|
||||
describe('listTripPhotos / listTripAlbumLinks — parity', () => {
|
||||
it('PARITY-UM2: matches the legacy join, enabled/disabled providers mixed', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// A photo under a disabled provider must not appear — the legacy `tkp.provider IN (...)`
|
||||
// only ever names the enabled set.
|
||||
testDb.prepare(
|
||||
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, 50)'
|
||||
).run('disabled-x', 'Disabled X', 'desc', 'Image');
|
||||
|
||||
const enabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-enabled', user.id).lastInsertRowid as number;
|
||||
const disabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('disabled-x', 'asset-disabled', user.id).lastInsertRowid as number;
|
||||
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, enabledPhoto);
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, disabledPhoto);
|
||||
|
||||
const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id);
|
||||
const oracle = testDb.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(trip.id, user.id, ...enabledProviders);
|
||||
|
||||
const result = await listTripPhotos(String(trip.id), user.id);
|
||||
const data = expectSuccess(result);
|
||||
expect(data).toEqual(oracle);
|
||||
expect(data.some((r: { provider: string }) => r.provider === 'disabled-x')).toBe(false);
|
||||
});
|
||||
|
||||
it('PARITY-UM3: matches the legacy join for an album link with a passphrase', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await createTripAlbumLink(String(trip.id), user.id, 'synologyphotos', 'album-p1', 'Passphrase Album', 'secret-pass');
|
||||
|
||||
const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id);
|
||||
const oracle = testDb.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(trip.id, ...enabledProviders);
|
||||
|
||||
const result = await listTripAlbumLinks(String(trip.id), user.id);
|
||||
const data = expectSuccess(result);
|
||||
expect(data).toEqual(oracle);
|
||||
// The passphrase column round-trips through the repository encrypted, same
|
||||
// as it always was — not part of this listing's SELECT list either way.
|
||||
expect(data[0].album_id).toBe('album-p1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Access-check mutation proof — a non-member (not "trip missing") ───────────
|
||||
|
||||
describe('access check — non-member refusal', () => {
|
||||
it('MEM-UNIFIED-014: a real trip, a real non-member user, still 404s (red if the gate is loosened)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const result = await listTripPhotos(String(trip.id), stranger.id);
|
||||
expect(expectFailure(result).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── R8 — RealtimeService.broadcast pins ────────────────────────────────────────
|
||||
|
||||
describe('R8 — injected RealtimeService, not the legacy module broadcast', () => {
|
||||
it('MEM-UNIFIED-015: addTripPhotos calls this.realtime.broadcast with the exact legacy event/payload/arity', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-r8-1'] }], 'sid-1');
|
||||
|
||||
expect(expectSuccess(result).added).toBe(1);
|
||||
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
|
||||
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-1');
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-016: setTripPhotoSharing calls this.realtime.broadcast', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-2', user.id).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId);
|
||||
|
||||
const result = await setTripPhotoSharing(String(trip.id), user.id, photoId, true, 'sid-2');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
|
||||
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-2');
|
||||
const row = testDb.prepare('SELECT shared FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId) as { shared: number };
|
||||
expect(row.shared).toBe(1);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-018: UM4 insertIgnore reports 0 added on a re-add of the same photo (duplicate-ignore, not a crash)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const first = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-dup-1'] }], 'sid-4');
|
||||
expect(expectSuccess(first).added).toBe(1);
|
||||
realtimeMock.broadcast.mockClear();
|
||||
|
||||
const second = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-dup-1'] }], 'sid-5');
|
||||
expect(expectSuccess(second).added).toBe(0);
|
||||
|
||||
const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_photos WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number };
|
||||
expect(rows.c).toBe(1);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-017: removeTripPhoto calls this.realtime.broadcast and deletes the row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-3', user.id).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId);
|
||||
|
||||
const result = await removeTripPhoto(String(trip.id), user.id, photoId, 'sid-3');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
|
||||
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-3');
|
||||
const row = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user