mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(synology): paginate all three album sources past 100 albums and tighten targetUserId type
- Extract _fetchAllSynologyAlbums helper that loops until the source is exhausted; listSynologyAlbums now uses it for personal, shared-out, and shared-with-me instead of a hard-capped single request of 100 - Make getSynologyAssetInfo targetUserId required (number, not number|undefined) to match every call site and eliminate an implicit any at the _requestSynologyApi boundary
This commit is contained in:
@@ -85,7 +85,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// Album linking
|
||||
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([])
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||
const [syncing, setSyncing] = useState<number | null>(null)
|
||||
@@ -141,7 +141,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
await loadAlbums(selectedProvider)
|
||||
}
|
||||
|
||||
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||
const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => {
|
||||
if (!selectedProvider) {
|
||||
toast.error(t('memories.error.linkAlbum'))
|
||||
return
|
||||
@@ -152,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
album_id: albumId,
|
||||
album_name: albumName,
|
||||
provider: selectedProvider,
|
||||
...(passphrase ? { passphrase } : {}),
|
||||
})
|
||||
setShowAlbumPicker(false)
|
||||
await loadAlbumLinks()
|
||||
@@ -489,7 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{albums.map(album => {
|
||||
const isLinked = linkedIds.has(album.id)
|
||||
return (
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName, album.passphrase)}
|
||||
disabled={isLinked}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||
|
||||
@@ -1455,8 +1455,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
@@ -1518,13 +1519,14 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
|
||||
const loadAlbumPhotos = async (albumId: string) => {
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
|
||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
@@ -1643,7 +1645,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
@@ -1773,13 +1775,13 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1629,6 +1629,11 @@ function runMigrations(db: Database.Database): void {
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 104: Passphrase support for Synology shared-album links (#689)
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -80,7 +80,8 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
@@ -115,12 +116,13 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, photoId, ownerId } = req.params;
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else {
|
||||
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
|
||||
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,6 +132,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
|
||||
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
|
||||
const rawSize = String(req.query.size ?? 'sm');
|
||||
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (kind !== 'thumbnail' && kind !== 'original') {
|
||||
return handleServiceResult(res, fail('Invalid asset kind', 400));
|
||||
@@ -139,7 +142,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else{
|
||||
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size));
|
||||
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -84,7 +84,8 @@ router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, re
|
||||
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
|
||||
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
|
||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -432,31 +432,37 @@ export async function testSynologyConnection(userId: number, synologyUrl: string
|
||||
return success({ connected: true, user: { name: synologyUsername } });
|
||||
}
|
||||
|
||||
async function _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise<ServiceResult<any[]>> {
|
||||
const pageSize = 100;
|
||||
const all: any[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize });
|
||||
if (!result.success) return result as ServiceResult<any[]>;
|
||||
const items = result.data.list || [];
|
||||
all.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
return success(all);
|
||||
}
|
||||
|
||||
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
|
||||
const [personal, shared, sharedWithMe] = await Promise.allSettled([
|
||||
_requestSynologyApi<{ list: any[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4,
|
||||
offset: 0, limit: 100,
|
||||
}),
|
||||
_requestSynologyApi<{ list: any[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4,
|
||||
offset: 0, limit: 100, category: 'shared',
|
||||
}),
|
||||
_requestSynologyApi<{ list: any[] }>(userId, {
|
||||
api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1,
|
||||
offset: 0, limit: 100, additional: ['thumbnail', 'sharing_info'],
|
||||
}),
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }),
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }),
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }),
|
||||
]);
|
||||
|
||||
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
|
||||
|
||||
const addAlbums = (result: PromiseSettledResult<ServiceResult<{ list: any[] }>>, extractPassphrase: (a: any) => string | undefined) => {
|
||||
const addAlbums = (result: PromiseSettledResult<ServiceResult<any[]>>, extractPassphrase: (a: any) => string | undefined) => {
|
||||
if (result.status === 'rejected') return;
|
||||
if (!result.value.success) {
|
||||
console.warn('[Synology] album list partial failure:', (result.value as any).error?.message);
|
||||
return;
|
||||
}
|
||||
for (const album of (result.value as any).data?.list ?? []) {
|
||||
for (const album of result.value.data ?? []) {
|
||||
const id = String(album.id);
|
||||
const passphrase = extractPassphrase(album);
|
||||
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
|
||||
@@ -478,7 +484,7 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
|
||||
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
@@ -508,7 +514,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
|
||||
const { albumId, passphrase } = response.data;
|
||||
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
@@ -575,7 +581,7 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number, passphrase?: string): 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 infoParams: ApiCallParams = {
|
||||
|
||||
@@ -350,6 +350,7 @@ export interface TrekPhoto {
|
||||
thumbnail_path?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
passphrase?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1149,9 +1149,15 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
|
||||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||||
|
||||
// Auth succeeds, but the first album-list call throws SsrfBlockedError.
|
||||
// The other two parallel album sources fall through to the default mock and succeed.
|
||||
// listSynologyAlbums uses Promise.allSettled so a partial failure is logged and skipped.
|
||||
const emptyAlbumResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Shared Album', item_count: 2, passphrase: 'pp-test' }] } }),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Auth succeeds, personal album source throws SSRF, shared + shared-with-me succeed.
|
||||
// listSynologyAlbums uses Promise.allSettled so the SSRF failure is logged and skipped.
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
@@ -1159,15 +1165,17 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'));
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'))
|
||||
.mockResolvedValueOnce(emptyAlbumResponse)
|
||||
.mockResolvedValueOnce(emptyAlbumResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// The personal album source failed, but the other sources succeeded via the default mock.
|
||||
// listSynologyAlbums is resilient: partial failure is logged, remaining albums returned.
|
||||
// Personal failed (SSRF), shared sources returned an album — 200 with non-empty list.
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user