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 = {
|
export type Selection = {
|
||||||
provider: string;
|
provider: string;
|
||||||
asset_ids: string[];
|
asset_ids: string[];
|
||||||
|
passphrase?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatusResult = {
|
export type StatusResult = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichS
|
|||||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||||
import { fail, success } from './helpersService';
|
import { fail, success } from './helpersService';
|
||||||
|
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||||
|
|
||||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -14,15 +15,22 @@ export function getOrCreateTrekPhoto(
|
|||||||
provider: string,
|
provider: string,
|
||||||
assetId: string,
|
assetId: string,
|
||||||
ownerId: number,
|
ownerId: number,
|
||||||
|
passphrase?: string,
|
||||||
): number {
|
): number {
|
||||||
const existing = db.prepare(
|
const existing = db.prepare(
|
||||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
).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(
|
const res = db.prepare(
|
||||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)'
|
||||||
).run(provider, assetId, ownerId);
|
).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null);
|
||||||
return Number(res.lastInsertRowid);
|
return Number(res.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +85,8 @@ export async function streamPhoto(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'synologyphotos': {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -112,7 +121,8 @@ export async function getPhotoInfo(
|
|||||||
return success(result.data as AssetInfo);
|
return success(result.data as AssetInfo);
|
||||||
}
|
}
|
||||||
case 'synologyphotos': {
|
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:
|
default:
|
||||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
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 { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
|
||||||
import { addTripPhotos } from './unifiedService';
|
import { addTripPhotos } from './unifiedService';
|
||||||
import {
|
import {
|
||||||
getAlbumIdFromLink,
|
getAlbumLinkForSync,
|
||||||
updateSyncTimeForAlbumLink,
|
updateSyncTimeForAlbumLink,
|
||||||
Selection,
|
Selection,
|
||||||
ServiceResult,
|
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 allItems: SynologyPhotoItem[] = [];
|
||||||
const pageSize = 1000;
|
const pageSize = 1000;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
const params: ApiCallParams = passphrase
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||||
method: 'list',
|
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||||
version: 1,
|
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
|
||||||
album_id: Number(albumId),
|
|
||||||
offset,
|
|
||||||
limit: pageSize,
|
|
||||||
additional: ['thumbnail'],
|
|
||||||
});
|
|
||||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||||
const items = result.data.list || [];
|
const items = result.data.list || [];
|
||||||
allItems.push(...items);
|
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>> {
|
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>;
|
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||||
|
|
||||||
|
const { albumId, passphrase } = response.data;
|
||||||
|
|
||||||
const allItems: SynologyPhotoItem[] = [];
|
const allItems: SynologyPhotoItem[] = [];
|
||||||
const pageSize = 1000;
|
const pageSize = 1000;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
const itemParams: ApiCallParams = passphrase
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||||
method: 'list',
|
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||||
version: 1,
|
|
||||||
album_id: Number(response.data),
|
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
|
||||||
offset,
|
|
||||||
limit: pageSize,
|
|
||||||
additional: ['thumbnail'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||||
|
|
||||||
@@ -536,9 +529,9 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
|
|||||||
const selection: Selection = {
|
const selection: Selection = {
|
||||||
provider: SYNOLOGY_PROVIDER,
|
provider: SYNOLOGY_PROVIDER,
|
||||||
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
|
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);
|
const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId);
|
||||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
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);
|
const parsedId = _splitPackedSynologyId(photoId);
|
||||||
if (!parsedId) return fail('Invalid photo ID format', 400);
|
if (!parsedId) return fail('Invalid photo ID format', 400);
|
||||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
|
const infoParams: ApiCallParams = {
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
api: 'SYNO.Foto.Browse.Item',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
version: 5,
|
version: 5,
|
||||||
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
|
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'],
|
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>;
|
if (!result.success) return result as ServiceResult<AssetInfo>;
|
||||||
|
|
||||||
@@ -609,6 +604,8 @@ export async function streamSynologyAsset(
|
|||||||
targetUserId: number,
|
targetUserId: number,
|
||||||
photoId: string,
|
photoId: string,
|
||||||
kind: 'thumbnail' | 'original',
|
kind: 'thumbnail' | 'original',
|
||||||
|
size?: string,
|
||||||
|
passphrase?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const parsedId = _splitPackedSynologyId(photoId);
|
const parsedId = _splitPackedSynologyId(photoId);
|
||||||
if (!parsedId) {
|
if (!parsedId) {
|
||||||
@@ -634,6 +631,7 @@ export async function streamSynologyAsset(
|
|||||||
|
|
||||||
|
|
||||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||||
|
const resolvedSize = size || 'sm';
|
||||||
const params = kind === 'thumbnail'
|
const params = kind === 'thumbnail'
|
||||||
? new URLSearchParams({
|
? new URLSearchParams({
|
||||||
api: 'SYNO.Foto.Thumbnail',
|
api: 'SYNO.Foto.Thumbnail',
|
||||||
@@ -642,7 +640,7 @@ export async function streamSynologyAsset(
|
|||||||
mode: 'download',
|
mode: 'download',
|
||||||
id: parsedId.id,
|
id: parsedId.id,
|
||||||
type: 'unit',
|
type: 'unit',
|
||||||
size: 'sm',
|
size: resolvedSize,
|
||||||
cache_key: parsedId.cacheKey,
|
cache_key: parsedId.cacheKey,
|
||||||
_sid: sid.data,
|
_sid: sid.data,
|
||||||
})
|
})
|
||||||
@@ -654,6 +652,7 @@ export async function streamSynologyAsset(
|
|||||||
unit_id: `[${parsedId.id}]`,
|
unit_id: `[${parsedId.id}]`,
|
||||||
_sid: sid.data,
|
_sid: sid.data,
|
||||||
});
|
});
|
||||||
|
if (passphrase) params.append('passphrase', passphrase);
|
||||||
|
|
||||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||||
await pipeAsset(url, response)
|
await pipeAsset(url, response)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Selection,
|
Selection,
|
||||||
} from './helpersService';
|
} from './helpersService';
|
||||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||||
|
import { encrypt_api_key } from '../apiKeyCrypto';
|
||||||
|
|
||||||
|
|
||||||
function _providers(): Array<{id: string; enabled: boolean}> {
|
function _providers(): Array<{id: string; enabled: boolean}> {
|
||||||
@@ -104,13 +105,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul
|
|||||||
//-----------------------------------------------
|
//-----------------------------------------------
|
||||||
// managing photos in trip
|
// 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);
|
const providerResult = _validProvider(provider);
|
||||||
if (!providerResult.success) {
|
if (!providerResult.success) {
|
||||||
return providerResult as ServiceResult<boolean>;
|
return providerResult as ServiceResult<boolean>;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
|
'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);
|
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
|
||||||
@@ -147,7 +148,7 @@ export async function addTripPhotos(
|
|||||||
for (const raw of selection.asset_ids) {
|
for (const raw of selection.asset_ids) {
|
||||||
const assetId = String(raw || '').trim();
|
const assetId = String(raw || '').trim();
|
||||||
if (!assetId) continue;
|
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) {
|
if (!result.success) {
|
||||||
return result as ServiceResult<{ added: number; shared: boolean }>;
|
return result as ServiceResult<{ added: number; shared: boolean }>;
|
||||||
}
|
}
|
||||||
@@ -222,7 +223,7 @@ export function removeTripPhoto(
|
|||||||
// ----------------------------------------------
|
// ----------------------------------------------
|
||||||
// managing album links in trip
|
// 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);
|
const access = canAccessTrip(tripId, userId);
|
||||||
if (!access) {
|
if (!access) {
|
||||||
return fail('Trip not found or access denied', 404);
|
return fail('Trip not found or access denied', 404);
|
||||||
@@ -246,9 +247,10 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
).run(tripId, userId, provider, albumId, albumName);
|
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return fail('Album already linked', 409);
|
return fail('Album already linked', 409);
|
||||||
|
|||||||
@@ -704,6 +704,7 @@ describe('Synology auth checks', () => {
|
|||||||
// ── Album sync ────────────────────────────────────────────────────────────────
|
// ── Album sync ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { addAlbumLink } from '../helpers/factories';
|
import { addAlbumLink } from '../helpers/factories';
|
||||||
|
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
|
||||||
|
|
||||||
describe('Synology syncSynologyAlbumLink', () => {
|
describe('Synology syncSynologyAlbumLink', () => {
|
||||||
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
||||||
@@ -765,6 +766,70 @@ describe('Synology syncSynologyAlbumLink', () => {
|
|||||||
it('SYNO-053 — POST sync without auth returns 401', async () => {
|
it('SYNO-053 — POST sync without auth returns 401', async () => {
|
||||||
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
|
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('SYNO-054 — POST sync with passphrase link: uses passphrase in item-list call and persists encrypted passphrase on trek_photos', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||||
|
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||||
|
|
||||||
|
// Insert a link with an encrypted passphrase directly into the DB.
|
||||||
|
const rawPassphrase = 'syno-share-pass-abc';
|
||||||
|
const result = testDb.prepare(
|
||||||
|
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(trip.id, user.id, 'synologyphotos', '99', 'Shared Album', encrypt_api_key(rawPassphrase));
|
||||||
|
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as any;
|
||||||
|
|
||||||
|
// Override safeFetch so browse-item only succeeds when called with the passphrase param.
|
||||||
|
vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => {
|
||||||
|
const bodyParams = init?.body instanceof URLSearchParams
|
||||||
|
? init.body
|
||||||
|
: new URLSearchParams(String(init?.body ?? ''));
|
||||||
|
const apiName = bodyParams.get('api') || (new URL(String(url)).searchParams.get('api') ?? '');
|
||||||
|
|
||||||
|
if (apiName === 'SYNO.API.Auth') {
|
||||||
|
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'fake-sid-054' } }), body: null } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiName === 'SYNO.Foto.Browse.Item') {
|
||||||
|
// Only respond successfully when the passphrase param is present.
|
||||||
|
if (bodyParams.get('passphrase') !== rawPassphrase) {
|
||||||
|
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 105 } }), body: null } as any;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true, status: 200,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list: [{ id: 201, filename: 'shared.jpg', filesize: 512000, time: 1717228800, additional: { thumbnail: { cache_key: '201_sharedkey' } } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
body: null,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`SYNO-054: unexpected safeFetch call: api=${apiName}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.added).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The trek_photos row for the synced photo must have a non-null passphrase.
|
||||||
|
const photo = testDb.prepare(`
|
||||||
|
SELECT tkp.passphrase FROM trip_photos tp
|
||||||
|
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||||
|
WHERE tp.trip_id = ? AND tp.user_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(trip.id, user.id) as { passphrase: string | null } | undefined;
|
||||||
|
|
||||||
|
expect(photo).toBeDefined();
|
||||||
|
expect(photo!.passphrase).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Session retry logic ───────────────────────────────────────────────────────
|
// ── Session retry logic ───────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user