Compare commits

..

11 Commits

Author SHA1 Message Date
Julien G. d320e4d564 Merge fef12b0e8b into bffb55d8c0 2026-04-16 22:53:04 +02:00
jubnl fef12b0e8b fix(mobile): account for bottom navbar in overlays and improve system notices UX
- Add paddingBottom: var(--bottom-nav-h) to all mobile overlays that were
  clipping content behind the bottom navbar: EntryEditor, SystemNoticeModal,
  JourneyPage create modal, TodoListPanel sheets, TripPlannerPage
  PlaceInspector, PackingListPanel bag modal, both PhotoLightboxes,
  FileManager viewer, and shared Modal primitive
- Replace single-notice mobile bottom sheet with a 3-slot horizontal strip
  so adjacent notices are physically present during drag
- Add live-follow swipe left/right to navigate between notices with
  spring-back when under threshold and flushSync to eliminate blink on commit
- Add live-follow swipe down to dismiss all notices with spring-back;
  backdrop tap also triggers the slide-down animation
- Normalize notice height with useLayoutEffect minHeight on strip and
  align-items: stretch so all slots are always the tallest notice height
- Pin CTA button at consistent Y across notices via flex-1 + mt-auto;
  always render invisible Not now placeholder to equalise CTA section height
- Move pager dots/counter below CTA buttons
2026-04-16 22:49:20 +02:00
Julien G. bffb55d8c0 Merge pull request #699 from mauriceboe/fix/journey-gallery-lightbox-grouping
fix(journey): gallery lightbox navigates all photos, not just same-day entry
2026-04-16 21:43:07 +02:00
jubnl 5c24213b0e fix(journey): gallery lightbox navigates all photos, not just same-day entry 2026-04-16 21:35:52 +02:00
Julien G. 12a457801a Merge pull request #697 from mauriceboe/fix/journey-photo-thumbnail-cache
fix(journey): serve local file when uploading photos with Immich syncenabled
2026-04-16 21:29:59 +02:00
jubnl ae4d317dc3 fix(journey): serve local file when uploading photos with Immich sync enabled
After upload, trek_photos.provider is immediately flipped to 'immich' even
though Immich's thumbnail generation is async. streamPhoto then routed to
Immich, which returned an error for the not-yet-processed asset. Because
Cache-Control was set before the proxy attempt, the error response was cached
by the browser for 24h — breaking thumbnails until a hard refresh bypassed
the cache and Immich had finished processing.

- streamPhoto now prefers the local file_path when it exists on disk,
  regardless of provider; Immich/Synology are only used when no local
  file is available (fixes the immediate broken-thumbnail symptom)
- pipeAsset sets Cache-Control: no-store on upstream errors and uses the
  caller-supplied default only on success (prevents cache poisoning)
- streamImmichAsset no longer pre-sets Cache-Control before the proxy
- streamSynologyAsset passes the same defaultCacheControl through pipeAsset

Closes #691
2026-04-16 21:20:38 +02:00
Julien G. f7c6854059 Merge pull request #693 from mauriceboe/fix/synology-shared-albums-pagination
Fix/synology shared albums pagination
2026-04-16 21:06:28 +02:00
jubnl bdb6b01765 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
2026-04-16 20:54:35 +02:00
jubnl 129dfabaa3 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.
2026-04-16 20:05:18 +02:00
jubnl 8a6d1b2aaf feat(synology): merge personal, shared-out, and shared-with-me albums in listSynologyAlbums
Fire all three Synology album sources in parallel via Promise.allSettled so a
permissions failure on one source (e.g. SYNO.Foto.Sharing.Misc) never blocks
personal album display. Deduplicate by album id (last-write-wins), propagate
passphrase from shared/shared-with-me entries, and return the merged list sorted
by albumName. Extends AlbumsList type to carry optional passphrase.

Adds SYNO-027/028/029 integration tests; updates SYNO-060/061/081 to match
the new multi-source call pattern.
2026-04-16 19:56:10 +02:00
jubnl 465b78411a fix(synology): resolve pagination offset using correct size before computing page offset
The `size` → `limit` assignment was evaluated after `page * limit`, causing
the offset to be computed using the hardcoded default (100) instead of the
caller-supplied page size. Swapping the two `if` blocks ensures `limit` is
resolved from `size` first so the offset is always `(page-1) * size`.

Adds SYNO-025 and SYNO-026 integration tests that capture the raw Synology
API body and assert `offset` and `limit` are forwarded correctly.
2026-04-16 19:49:08 +02:00
21 changed files with 684 additions and 141 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
paddingBottom: 'var(--bottom-nav-h)',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
@@ -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',
@@ -1268,7 +1268,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
{/* Main area */}
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
@@ -69,7 +70,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
: DefaultIcon;
return (
<div className="flex flex-col relative">
<div className="flex flex-col relative flex-1">
{/* Dismiss X button */}
{notice.dismissible && (
<button
@@ -98,7 +99,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</div>
)}
<div className="p-8">
<div className="px-8 pt-8 pb-0 sm:pb-8 flex flex-col flex-1">
{/* Severity icon (when no hero) */}
{!notice.media && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
@@ -179,9 +180,36 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</ul>
)}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-auto">
{ctaLabel ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{ctaLabel}
</button>
) : (
<button
id={`notice-cta-${notice.id}`}
onClick={onDismissAll}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{t('common.ok')}
</button>
)}
<button
onClick={notice.dismissible && ctaLabel ? onDismiss : undefined}
className={`text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors ${!(notice.dismissible && ctaLabel) ? 'invisible' : ''}`}
>
Not now
</button>
</div>
{/* Pager — dots, arrows, counter (only when multiple notices) */}
{total > 1 && (
<div className="flex flex-col items-center gap-1 mb-4">
<div className="flex flex-col items-center gap-1 mt-6 mb-0">
<div className="flex items-center gap-2">
<button
onClick={onPrev}
@@ -224,35 +252,6 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</span>
</div>
)}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-2">
{ctaLabel ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{ctaLabel}
</button>
) : (
<button
id={`notice-cta-${notice.id}`}
onClick={onDismissAll}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{t('common.ok')}
</button>
)}
{notice.dismissible && ctaLabel && (
<button
onClick={onDismiss}
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
Not now
</button>
)}
</div>
</div>
</div>
);
@@ -283,7 +282,10 @@ export function ModalRenderer({ notices }: Props) {
// Non-dismissible notices lock the pager so users must act before advancing.
const canPage = notice?.dismissible !== false;
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
const dragLockRef = useRef<'h' | 'v' | null>(null);
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
const noticeIdRef = useRef<string | null>(null);
noticeIdRef.current = notice?.id ?? null;
@@ -295,6 +297,13 @@ export function ModalRenderer({ notices }: Props) {
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
const isPageNavRef = useRef(false);
const slideDirRef = useRef<'left' | 'right'>('right');
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
const stripRef = useRef<HTMLDivElement>(null);
// The sheet element itself — animated on vertical drag-to-dismiss
const sheetRef = useRef<HTMLDivElement>(null);
// Clip container ref + cached max height — used to pin sheet height to tallest notice
const clipRef = useRef<HTMLDivElement>(null);
const maxClipHeightRef = useRef(0);
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Mobile breakpoint
@@ -410,6 +419,19 @@ export function ModalRenderer({ notices }: Props) {
return () => { document.body.style.overflow = ''; };
}, [visible, notice]);
// Pin the strip to the tallest notice height seen so far.
// Setting minHeight on the strip (not the clip) forces align-items:stretch to
// make every slot exactly that tall, so mt-auto always bottoms out at the same Y.
useLayoutEffect(() => {
if (!isMobile) return;
const el = stripRef.current;
if (!el) return;
el.style.minHeight = '';
const h = el.scrollHeight;
if (h > maxClipHeightRef.current) maxClipHeightRef.current = h;
el.style.minHeight = `${maxClipHeightRef.current}px`;
});
function announceIndex(newIdx: number, total: number) {
setPageAnnouncement(
t('system_notice.pager.position')
@@ -453,6 +475,17 @@ export function ModalRenderer({ notices }: Props) {
}
}
function animatedDismissAll() {
const sheet = sheetRef.current;
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
sheet.style.transition = 'transform 300ms ease-out';
sheet.style.transform = 'translateY(110%)';
sheet.addEventListener('transitionend', function onDone() {
sheet.removeEventListener('transitionend', onDone);
handleDismissAll();
}, { once: true });
}
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
// re-renders with the new notice), then flags the grace-delay effect to slide
// rather than hide+show.
@@ -531,6 +564,38 @@ export function ModalRenderer({ notices }: Props) {
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
const slotRawBody = t(n.bodyKey);
const slotBody = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
slotRawBody
)
: slotRawBody;
return {
notice: n,
title: t(n.titleKey),
body: slotBody,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: slotIdx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
}
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
@@ -538,30 +603,136 @@ export function ModalRenderer({ notices }: Props) {
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? handleDismiss : undefined}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
onTouchEnd={e => {
if (touchStartY.current !== null && notice.dismissible) {
const delta = e.changedTouches[0].clientY - touchStartY.current;
if (delta > 80) handleDismiss();
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ paddingBottom: 'var(--bottom-nav-h)', touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8)
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}
// Vertical drag — animated dismiss or spring back
if (lock === 'v' && startY !== null) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
}
}}
>
{/* Drag handle */}
<div className="pt-3 pb-1 flex justify-center">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
{/* Clip container — hides the adjacent slots outside the sheet width */}
<div style={{ overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...contentProps} />
</div>
<div style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
</div>
</div>
</div>
</div>
</div>
+2 -2
View File
@@ -394,7 +394,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
)}
{selectedItem && !isAddingNew && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<DetailPane
@@ -419,7 +419,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
)}
{isAddingNew && !selectedItem && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<NewTaskPane
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Modal({
return (
<div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
+11 -9
View File
@@ -908,11 +908,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map(({ photo, entry }) => (
{allPhotos.map(({ photo, entry }, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
>
<img
src={photoUrl(photo, 'thumbnail')}
@@ -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
}}
/>
@@ -2028,7 +2030,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
+1 -1
View File
@@ -279,7 +279,7 @@ export default function JourneyPage() {
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
+1 -1
View File
@@ -838,7 +838,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{selectedPlace && isMobile && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
<PlaceInspector
place={selectedPlace}
+5
View File
@@ -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) {
+8 -5
View File
@@ -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) => {
@@ -100,8 +101,8 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if(page > 0) offset = page*limit;
if(size > 0) limit = size;
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id,
@@ -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);
}
});
+2 -1
View File
@@ -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 });
});
+28 -3
View File
@@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
import { Response } from 'express';
import { canAccessTrip, db } from "../../db/database";
import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../apiKeyCrypto';
// helpers for handling return types
@@ -42,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 = {
@@ -59,7 +61,7 @@ export type SyncAlbumResult = {
export type AlbumsList = {
albums: Array<{ id: string; albumName: string; assetCount: number }>
albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
};
export type Asset = {
@@ -230,17 +232,40 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe
}
}
export function getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
const access = canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = db.prepare('SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.get(linkId, tripId, userId) as { album_id: string; passphrase: string | null } | null;
if (!row) return fail('Album link not found', 404);
const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined;
return success({ albumId: row.album_id, passphrase: decrypted || undefined });
} catch {
return fail('Failed to retrieve album link', 500);
}
}
export function updateSyncTimeForAlbumLink(linkId: string): void {
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
}
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal): Promise<void> {
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string): Promise<void> {
try {
const resp = await safeFetch(url, { headers, signal: signal as any });
response.status(resp.status);
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
if (!resp.ok) {
response.set('Cache-Control', 'no-store, max-age=0');
} else if (resp.headers.get('cache-control')) {
response.set('Cache-Control', resp.headers.get('cache-control') as string);
} else if (defaultCacheControl) {
response.set('Cache-Control', defaultCacheControl);
}
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
@@ -246,8 +246,7 @@ export async function streamImmichAsset(
? `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`
: `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
response.set('Cache-Control', 'public, max-age=86400');
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout));
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout), 'public, max-age=86400');
}
// ── Albums ──────────────────────────────────────────────────────────────────
@@ -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);
}
@@ -61,15 +69,18 @@ export async function streamPhoto(
return;
}
if (photo.file_path) {
const localPath = path.join(__dirname, '../../../uploads', photo.file_path);
if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(localPath);
return;
}
}
switch (photo.provider) {
case 'local': {
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'File not found' });
return;
}
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(filePath);
res.status(404).json({ error: 'File not found' });
return;
}
case 'immich': {
@@ -77,7 +88,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 +124,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);
+71 -42
View File
@@ -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,
@@ -432,41 +432,66 @@ 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 result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Album',
method: 'list',
version: 4,
offset: 0,
limit: 100,
});
if (!result.success) return result as ServiceResult<AlbumsList>;
const [personal, shared, sharedWithMe] = await Promise.allSettled([
_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 albums = (result.data.list || []).map((album: any) => ({
id: String(album.id),
albumName: album.name || '',
assetCount: album.item_count || 0,
}));
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
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.data ?? []) {
const id = String(album.id);
const passphrase = extractPassphrase(album);
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
}
};
addAlbums(personal, () => undefined);
addAlbums(shared, (a) => a.passphrase || undefined);
addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined);
if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) {
return personal.value as ServiceResult<AlbumsList>;
}
const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName));
return success({ albums });
}
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;
const pageSize = 50;
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);
@@ -483,23 +508,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;
const pageSize = 50;
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>;
@@ -512,9 +535,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>;
@@ -558,16 +581,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>;
@@ -585,6 +610,8 @@ export async function streamSynologyAsset(
targetUserId: number,
photoId: string,
kind: 'thumbnail' | 'original',
size?: string,
passphrase?: string,
): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) {
@@ -610,6 +637,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',
@@ -618,7 +646,7 @@ export async function streamSynologyAsset(
mode: 'download',
id: parsedId.id,
type: 'unit',
size: 'sm',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
@@ -630,8 +658,9 @@ 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)
await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400')
}
@@ -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);
+1
View File
@@ -350,6 +350,7 @@ export interface TrekPhoto {
thumbnail_path?: string | null;
width?: number | null;
height?: number | null;
passphrase?: string | null;
created_at: string;
}
@@ -396,6 +396,139 @@ describe('Synology search and albums', () => {
});
});
// ── Album listing — multi-source merge ───────────────────────────────────────
describe('Synology listSynologyAlbums multi-source merge', () => {
// Capture and restore the default safeFetch implementation around each test
// in this block so the persistent mockImplementation we set doesn't leak.
let _savedImpl: ((...args: any[]) => any) | undefined;
beforeEach(() => { _savedImpl = vi.mocked(safeFetch).getMockImplementation(); });
afterEach(() => { if (_savedImpl) vi.mocked(safeFetch).mockImplementation(_savedImpl); });
it('SYNO-027 — personal-only: shared and shared-with-me return failure → merged result contains personal albums, no error', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
// Always read both URL params and body params; body takes precedence for request-specific fields.
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-027' } }), body: null } as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
// personal albums
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 1, name: 'Personal Album', item_count: 5 }] } }), body: null } as any);
}
// shared category → failure
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums).toHaveLength(1);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Personal Album', assetCount: 5 });
});
it('SYNO-028 — full merge: personal + shared (with passphrase) + shared-with-me (with sharing_info.passphrase) → 4 albums with correct passphrases', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-028' } }), body: null } as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 10, name: 'Alpha Album', item_count: 3 }, { id: 11, name: 'Beta Album', item_count: 7 }] } }), body: null } as any);
}
// shared category — one album with passphrase
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 20, name: 'Shared Out', item_count: 2, passphrase: 'pp-abc' }] } }), body: null } as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 30, name: 'Shared With Me', item_count: 4, sharing_info: { passphrase: 'pp-xyz' } }] } }), body: null } as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums).toHaveLength(4);
const byName = (name: string) => res.body.albums.find((a: any) => a.albumName === name);
expect(byName('Alpha Album')).toMatchObject({ id: '10', assetCount: 3 });
expect(byName('Beta Album')).toMatchObject({ id: '11', assetCount: 7 });
expect(byName('Shared Out')).toMatchObject({ id: '20', passphrase: 'pp-abc' });
expect(byName('Shared With Me')).toMatchObject({ id: '30', passphrase: 'pp-xyz' });
// personal albums carry no passphrase
expect(byName('Alpha Album').passphrase).toBeUndefined();
});
it('SYNO-029 — dedup: same album id=99 in personal and shared-with-me → last-write-wins gives passphrase from shared-with-me', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-029' } }), body: null } as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
// personal: album id=99 without passphrase
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10 }] } }), body: null } as any);
}
// shared: no entries
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [] } }), body: null } as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
// shared-with-me: same album id=99 with passphrase
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10, passphrase: 'pp-dup' }] } }), body: null } as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
// Deduplicated to a single album
expect(res.body.albums).toHaveLength(1);
expect(res.body.albums[0]).toMatchObject({ id: '99', albumName: 'Dup Album' });
// shared-with-me wins (last write) → passphrase present
expect(res.body.albums[0].passphrase).toBe('pp-dup');
});
});
// ── Asset access ──────────────────────────────────────────────────────────────
describe('Synology asset access', () => {
@@ -571,6 +704,7 @@ describe('Synology auth checks', () => {
// ── Album sync ────────────────────────────────────────────────────────────────
import { addAlbumLink } from '../helpers/factories';
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
describe('Synology syncSynologyAlbumLink', () => {
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
@@ -632,6 +766,70 @@ describe('Synology syncSynologyAlbumLink', () => {
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);
});
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 ───────────────────────────────────────────────────────
@@ -691,8 +889,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
// Four safeFetch calls: login, failed album list, re-login, successful album list
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
// Five safeFetch calls: login, failed album list (119), re-login, successful album list retry,
// plus one additional call for the shared or shared-with-me source (handled by default mock)
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
});
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
@@ -735,7 +934,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
expect(res.status).toBe(200);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
// Five safeFetch calls: login, failed album list (106), re-login, successful album list retry,
// plus one additional call for the shared or shared-with-me source (handled by default mock)
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
});
});
@@ -843,6 +1044,83 @@ describe('Synology searchSynologyPhotos date range', () => {
});
});
// ── Search pagination ─────────────────────────────────────────────────────────
describe('Synology search pagination', () => {
it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
// login
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody = init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 2, size: 50 });
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
// With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50
expect(capturedBody!.get('offset')).toBe('50');
expect(capturedBody!.get('limit')).toBe('50');
});
it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody = init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 3, size: 25 });
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
// page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50
expect(capturedBody!.get('offset')).toBe('50');
expect(capturedBody!.get('limit')).toBe('25');
});
});
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
describe('Synology SSRF blocked error handling', () => {
@@ -865,13 +1143,21 @@ describe('Synology SSRF blocked error handling', () => {
expect(res.body.connected).toBe(false);
});
it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => {
it('SYNO-081 — safeFetch throwing SsrfBlockedError during one album source is swallowed; other sources still return albums', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
// Auth succeeds, but the album-list call throws SsrfBlockedError
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,
@@ -879,14 +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));
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
// 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);
});
});