mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(synology): persist and use passphrase for shared album photo streaming (#689-4)
- syncSynologyAlbumLink now uses getAlbumLinkForSync to read the stored passphrase and passes it in the SYNO.Foto.Browse.Item call when present, falling back to album_id for links without a passphrase. - Selection type gains optional passphrase field; addTripPhotos and _addTripPhoto thread it through to getOrCreateTrekPhoto. - getOrCreateTrekPhoto accepts an optional passphrase (4th param) and encrypts it when inserting a new trek_photos row; backfills existing rows that lack a passphrase. - streamPhoto and getPhotoInfo decrypt the stored passphrase from trek_photos and forward it to streamSynologyAsset / getSynologyAssetInfo so shared-album photos resolve correctly at access time. - Add SYNO-054 integration test covering the passphrase sync-and-persist path end-to-end.
This commit is contained in:
@@ -43,6 +43,7 @@ export function handleServiceResult<T>(res: Response, result: ServiceResult<T>):
|
||||
export type Selection = {
|
||||
provider: string;
|
||||
asset_ids: string[];
|
||||
passphrase?: string;
|
||||
};
|
||||
|
||||
export type StatusResult = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichS
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,15 +15,22 @@ export function getOrCreateTrekPhoto(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
passphrase?: string,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
if (existing) {
|
||||
if (passphrase) {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
|
||||
.run(encrypt_api_key(passphrase), existing.id);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, ownerId);
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)'
|
||||
).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
@@ -77,7 +85,8 @@ export async function streamPhoto(
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
@@ -112,7 +121,8 @@ export async function getPhotoInfo(
|
||||
return success(result.data as AssetInfo);
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase);
|
||||
}
|
||||
default:
|
||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiK
|
||||
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { addTripPhotos } from './unifiedService';
|
||||
import {
|
||||
getAlbumIdFromLink,
|
||||
getAlbumLinkForSync,
|
||||
updateSyncTimeForAlbumLink,
|
||||
Selection,
|
||||
ServiceResult,
|
||||
@@ -476,21 +476,16 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
|
||||
}
|
||||
|
||||
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(albumId),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
const params: ApiCallParams = passphrase
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
|
||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||
const items = result.data.list || [];
|
||||
allItems.push(...items);
|
||||
@@ -507,23 +502,21 @@ export async function getSynologyAlbumPhotos(userId: number, albumId: string): P
|
||||
}
|
||||
|
||||
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
|
||||
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||
const response = getAlbumLinkForSync(tripId, linkId, userId);
|
||||
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
const { albumId, passphrase } = response.data;
|
||||
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(response.data),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
const itemParams: ApiCallParams = passphrase
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
|
||||
|
||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
@@ -536,9 +529,9 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
|
||||
const selection: Selection = {
|
||||
provider: SYNOLOGY_PROVIDER,
|
||||
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
|
||||
passphrase,
|
||||
};
|
||||
|
||||
|
||||
const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId);
|
||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
@@ -582,16 +575,18 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<ServiceResult<AssetInfo>> {
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number, passphrase?: string): Promise<ServiceResult<AssetInfo>> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) return fail('Invalid photo ID format', 400);
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
|
||||
const infoParams: ApiCallParams = {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'get',
|
||||
version: 5,
|
||||
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
|
||||
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
||||
});
|
||||
};
|
||||
if (passphrase) infoParams.passphrase = passphrase;
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams);
|
||||
|
||||
if (!result.success) return result as ServiceResult<AssetInfo>;
|
||||
|
||||
@@ -609,6 +604,8 @@ export async function streamSynologyAsset(
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
kind: 'thumbnail' | 'original',
|
||||
size?: string,
|
||||
passphrase?: string,
|
||||
): Promise<void> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) {
|
||||
@@ -634,6 +631,7 @@ export async function streamSynologyAsset(
|
||||
|
||||
|
||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||
const resolvedSize = size || 'sm';
|
||||
const params = kind === 'thumbnail'
|
||||
? new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
@@ -642,7 +640,7 @@ export async function streamSynologyAsset(
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: 'sm',
|
||||
size: resolvedSize,
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
})
|
||||
@@ -654,6 +652,7 @@ export async function streamSynologyAsset(
|
||||
unit_id: `[${parsedId.id}]`,
|
||||
_sid: sid.data,
|
||||
});
|
||||
if (passphrase) params.append('passphrase', passphrase);
|
||||
|
||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||
await pipeAsset(url, response)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
import { encrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
|
||||
function _providers(): Array<{id: string; enabled: boolean}> {
|
||||
@@ -104,13 +105,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul
|
||||
//-----------------------------------------------
|
||||
// managing photos in trip
|
||||
|
||||
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult<boolean> {
|
||||
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
|
||||
const providerResult = _validProvider(provider);
|
||||
if (!providerResult.success) {
|
||||
return providerResult as ServiceResult<boolean>;
|
||||
}
|
||||
try {
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||
const result = 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);
|
||||
@@ -147,7 +148,7 @@ export async function addTripPhotos(
|
||||
for (const raw of selection.asset_ids) {
|
||||
const assetId = String(raw || '').trim();
|
||||
if (!assetId) continue;
|
||||
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId);
|
||||
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
|
||||
if (!result.success) {
|
||||
return result as ServiceResult<{ added: number; shared: boolean }>;
|
||||
}
|
||||
@@ -222,7 +223,7 @@ export function removeTripPhoto(
|
||||
// ----------------------------------------------
|
||||
// managing album links in trip
|
||||
|
||||
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> {
|
||||
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
if (!access) {
|
||||
return fail('Trip not found or access denied', 404);
|
||||
@@ -246,9 +247,10 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw:
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, provider, albumId, albumName);
|
||||
'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);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return fail('Album already linked', 409);
|
||||
|
||||
Reference in New Issue
Block a user