mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
27 Commits
d80bbd5bed
...
bffb55d8c0
| Author | SHA1 | Date | |
|---|---|---|---|
| bffb55d8c0 | |||
| 5c24213b0e | |||
| 12a457801a | |||
| ae4d317dc3 | |||
| f7c6854059 | |||
| bdb6b01765 | |||
| 129dfabaa3 | |||
| 8a6d1b2aaf | |||
| 465b78411a | |||
| 272b32b410 | |||
| 7945e752d6 | |||
| 6eb3ab38fb | |||
| c7a9210215 | |||
| d5d63aa979 | |||
| 84574020f2 | |||
| 1b7ea2c87d | |||
| 47b7678975 | |||
| da70388f4b | |||
| 6c1a795460 | |||
| 75d23eb6aa | |||
| 0c4de72356 | |||
| 5e8602c50a | |||
| 61b8070626 | |||
| 5caaeff67c | |||
| 92a1f9c448 | |||
| 58a8e97f94 | |||
| 815b725f87 |
@@ -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',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
|
||||
// that opens a new browser window and writes a full HTML document into it.
|
||||
// It does NOT render a React component. Tests verify window.open behaviour.
|
||||
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
|
||||
// Tests verify the overlay DOM structure and HTML content.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
} as unknown as JourneyDetail;
|
||||
}
|
||||
|
||||
// ── Mock window.open ─────────────────────────────────────────────────────────
|
||||
// ── Helpers to inspect the overlay ───────────────────────────────────────────
|
||||
|
||||
let mockWindow: {
|
||||
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
function getOverlay(): HTMLElement | null {
|
||||
return document.getElementById('journey-pdf-overlay');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindow = {
|
||||
document: { write: vi.fn(), close: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
|
||||
});
|
||||
function getIframe(): HTMLIFrameElement | null {
|
||||
return getOverlay()?.querySelector('iframe') ?? null;
|
||||
}
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('journey-pdf-overlay')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
expect(document.body.contains(getOverlay())).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const iframe = getIframe();
|
||||
expect(iframe).not.toBeNull();
|
||||
const html = iframe!.srcdoc;
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
|
||||
const overlay = getOverlay()!;
|
||||
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
|
||||
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Golden Circle');
|
||||
// Story text is rendered via markdown
|
||||
expect(html).toContain('An incredible day of geysers and waterfalls.');
|
||||
@@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
|
||||
const journey = buildJourney({ entries: [] });
|
||||
await downloadJourneyBookPDF(journey);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present
|
||||
expect(html).toContain('Journey Book');
|
||||
|
||||
@@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
|
||||
}
|
||||
|
||||
.print-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
|
||||
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
|
||||
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
|
||||
.print-bar .btn-save { background: white; color: #0f172a; }
|
||||
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-bar">
|
||||
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<button class="btn-save" onclick="window.print()">Save as PDF</button>
|
||||
<button class="btn-close" onclick="window.close()">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Page 1: Cover -->
|
||||
<div class="cover-page">
|
||||
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) return
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
|
||||
// This avoids window.open() which Safari iOS blocks in async callbacks
|
||||
// and window.close() which doesn't work reliably in standalone PWA mode.
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'journey-pdf-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
|
||||
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
|
||||
}
|
||||
|
||||
@@ -1023,7 +1023,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
|
||||
@@ -72,17 +72,18 @@ describe('BannerRenderer', () => {
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only last 2 shown)', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Oldest notice' });
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only top 2 shown)', async () => {
|
||||
// Server returns notices highest-priority first; BannerRenderer takes slice(0,2)
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Highest notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Newest notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Lowest notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2, n3]} />);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Oldest notice')).toBeNull();
|
||||
expect(screen.getByText('Highest notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
expect(screen.getByText('Newest notice')).toBeTruthy();
|
||||
expect(screen.queryByText('Lowest notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-005: critical banner has aria-live="assertive"', async () => {
|
||||
|
||||
@@ -2011,6 +2011,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'وضع لا اتصال كامل كتطبيق PWA',
|
||||
'system_notice.v3_features.highlight_search': 'إكمال تلقائي في الوقت الفعلي',
|
||||
'system_notice.v3_features.highlight_import': 'استيراد أماكن من ملفات KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: ترقية OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'تمت إعادة تصميم تكامل MCP بالكامل. OAuth 2.1 هو الآن طريقة المصادقة الموصى بها. الرموز الثابتة (trek_…) مهملة وستُزال في إصدار مستقبلي.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 موصى به (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
|
||||
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Modo offline completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompleção de lugares em tempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares de arquivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: atualização OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'A integração MCP foi completamente reformulada. OAuth 2.1 agora é o método de autenticação recomendado. Tokens estáticos (trek_…) foram descontinuados e serão removidos em uma versão futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 escopos de permissão granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ descontinuados',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Conjunto de ferramentas e prompts expandido',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
@@ -2218,6 +2218,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Plný offline režim jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autodoplňování vyhledávání míst',
|
||||
'system_notice.v3_features.highlight_import': 'Import míst ze souborů KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizace OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrace MCP byla kompletně přepracována. OAuth 2.1 je nyní doporučenou metodou ověřování. Statické tokeny (trek_…) jsou zastaralé a budou v budoucí verzi odstraněny.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 doporučeno (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 jemnozrnných oprávnění',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statické tokeny trek_ zastaralé',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozšířená sada nástrojů a promptů',
|
||||
}
|
||||
|
||||
export default cs
|
||||
|
||||
@@ -2218,6 +2218,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Vollständiger Offline-Modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Echtzeit-Autovervollständigung für Orte',
|
||||
'system_notice.v3_features.highlight_import': 'Orte aus KMZ/KML-Dateien importieren',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-Upgrade',
|
||||
'system_notice.v3_mcp.body': 'Die MCP-Integration wurde vollständig überarbeitet. OAuth 2.1 ist jetzt die empfohlene Authentifizierungsmethode. Statische Tokens (trek_…) sind veraltet und werden in einer zukünftigen Version entfernt.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 empfohlen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 feingranulare Berechtigungs-Scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-Tokens veraltet',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Erweitertes Toolset & Prompts',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -2240,6 +2240,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_search': 'Real-time place search autocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Import places from KMZ/KML files',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 upgrade',
|
||||
'system_notice.v3_mcp.body': 'The MCP integration has been fully overhauled. OAuth 2.1 is now the recommended auth method. Legacy static tokens (trek_\u2026) are deprecated and will be removed in a future release.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommended (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 fine-grained permission scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Static trek_ tokens deprecated',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Expanded toolset & prompts',
|
||||
|
||||
// System notices — onboarding
|
||||
'system_notice.welcome_v1.title': 'Welcome to TREK',
|
||||
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
|
||||
|
||||
@@ -2220,6 +2220,14 @@ const es: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Modo sin conexión completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompletado de lugares en tiempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares desde archivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: actualización OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'La integración MCP ha sido completamente renovada. OAuth 2.1 es ahora el método de autenticación recomendado. Los tokens estáticos (trek_…) están obsoletos y se eliminarán en una versión futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ obsoletos',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const fr: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Mode hors ligne complet en PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocomplétion des lieux en temps réel',
|
||||
'system_notice.v3_features.highlight_import': 'Importer des lieux depuis KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP : mise à niveau OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'intégration MCP a été entièrement repensée. OAuth 2.1 est désormais la méthode d'authentification recommandée. Les tokens statiques (trek_\u2026) sont dépréciés et seront supprimés dans une future version.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommandé (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scopes de permissions granulaires',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens statiques trek_ dépréciés',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Outils et prompts étendus',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
@@ -2215,6 +2215,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Teljes offline mód PWA-ként',
|
||||
'system_notice.v3_features.highlight_search': 'Valós idejű helykeresés-kiegészítés',
|
||||
'system_notice.v3_features.highlight_import': 'Helyek importálása KMZ/KML fájlokból',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 frissítés',
|
||||
'system_notice.v3_mcp.body': 'Az MCP integráció teljesen megújult. Az OAuth 2.1 mostantól az ajánlott hitelesítési módszer. A statikus tokenek (trek_…) elavultak és egy jövőbeli kiadásban eltávolításra kerülnek.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 ajánlott (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 részletes engedélyezési hatókör',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statikus trek_ tokenek elavultak',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Bővített eszközkészlet és promptok',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
@@ -2256,6 +2256,14 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Mode offline penuh sebagai PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Pelengkapan otomatis tempat secara real-time',
|
||||
'system_notice.v3_features.highlight_import': 'Impor tempat dari file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: pembaruan OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrasi MCP telah sepenuhnya diperbarui. OAuth 2.1 kini menjadi metode autentikasi yang direkomendasikan. Token statis (trek_…) sudah usang dan akan dihapus pada versi mendatang.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 direkomendasikan (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 cakupan izin yang terperinci',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statis trek_ sudah usang',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Perangkat dan prompt yang diperluas',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
@@ -2215,6 +2215,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Modalità offline completa come PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Completamento automatico luoghi in tempo reale',
|
||||
'system_notice.v3_features.highlight_import': 'Importa luoghi da file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aggiornamento OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'integrazione MCP è stata completamente rinnovata. OAuth 2.1 è ora il metodo di autenticazione consigliato. I token statici (trek_\u2026) sono deprecati e verranno rimossi in una versione futura.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 consigliato (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scope di autorizzazione granulari',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statici trek_ deprecati',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Strumenti e prompt estesi',
|
||||
}
|
||||
|
||||
export default it
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const nl: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Volledige offline modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Realtime plaatsautocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Importeer plaatsen uit KMZ/KML-bestanden',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-upgrade',
|
||||
'system_notice.v3_mcp.body': 'De MCP-integratie is volledig vernieuwd. OAuth 2.1 is nu de aanbevolen authenticatiemethode. Statische tokens (trek_…) zijn verouderd en worden verwijderd in een toekomstige versie.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 aanbevolen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 gedetailleerde toestemmingsscopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-tokens verouderd',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Uitgebreide tools & prompts',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
@@ -2207,6 +2207,14 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Pełny tryb offline jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autouzupełnianie wyszukiwania miejsc',
|
||||
'system_notice.v3_features.highlight_import': 'Import miejsc z plików KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizacja OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integracja MCP została całkowicie przeprojektowana. OAuth 2.1 jest teraz zalecaną metodą uwierzytelniania. Statyczne tokeny (trek_…) są przestarzałe i zostaną usunięte w przyszłej wersji.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 zalecany (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 szczegółowe zakresy uprawnień',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statyczne tokeny trek_ przestarzałe',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozszerzony zestaw narzędzi i promptów',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const ru: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Полный офлайн-режим как PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Автодополнение поиска мест в реальном времени',
|
||||
'system_notice.v3_features.highlight_import': 'Импорт мест из KMZ/KML-файлов',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: обновление OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Интеграция MCP была полностью переработана. OAuth 2.1 теперь является рекомендуемым методом аутентификации. Статические токены (trek_…) устарели и будут удалены в будущей версии.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 рекомендуется (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 детальных области разрешений',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Статические токены trek_ устарели',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Расширенный набор инструментов',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const zh: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': '作为 PWA 的完整离线模式',
|
||||
'system_notice.v3_features.highlight_search': '地点搜索实时自动补全',
|
||||
'system_notice.v3_features.highlight_import': '从 KMZ/KML 文件导入地点',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升级',
|
||||
'system_notice.v3_mcp.body': 'MCP 集成已全面重构。OAuth 2.1 现为推荐的身份验证方式。静态令牌(trek_…)已弃用,将在未来版本中移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 推荐(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 个细粒度权限范围',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '静态 trek_ 令牌已弃用',
|
||||
'system_notice.v3_mcp.highlight_tools': '扩展工具集与提示词',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
@@ -2215,6 +2215,14 @@ const zhTw: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': '作為 PWA 的完整離線模式',
|
||||
'system_notice.v3_features.highlight_search': '地點搜尋即時自動補全',
|
||||
'system_notice.v3_features.highlight_import': '從 KMZ/KML 檔案匯入地點',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升級',
|
||||
'system_notice.v3_mcp.body': 'MCP 整合已全面重構。OAuth 2.1 現為建議的身份驗證方式。靜態令牌(trek_…)已棄用,將於未來版本移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 建議(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 個細粒度權限範圍',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '靜態 trek_ 令牌已棄用',
|
||||
'system_notice.v3_mcp.highlight_tools': '擴展工具集與提示詞',
|
||||
}
|
||||
|
||||
export default zhTw
|
||||
@@ -323,7 +323,7 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbalken */
|
||||
/* Scrollbars — styled on desktop, hidden on mobile */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -333,21 +333,23 @@ body {
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
* { scrollbar-width: none; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
}
|
||||
|
||||
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
|
||||
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
|
||||
@@ -405,6 +407,7 @@ img[alt="TREK"] {
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
@@ -447,11 +450,6 @@ img[alt="TREK"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scroll-Container */
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db #f1f5f9;
|
||||
}
|
||||
|
||||
/* Toast-Animationen */
|
||||
@keyframes slideUp {
|
||||
|
||||
@@ -674,7 +674,10 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-027 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => {
|
||||
it('renders a spinner while journey data is loading', () => {
|
||||
// Do NOT await the waitFor -- we check the loading state before data arrives
|
||||
// Pre-seed the store into a loading state (current: null, loading: true).
|
||||
// We can't rely on render() timing because RTL wraps in act(), which flushes
|
||||
// all microtasks including the MSW response before render() returns.
|
||||
useJourneyStore.setState({ loading: true, current: null });
|
||||
render(<JourneyDetailPage />);
|
||||
// The spinner has animate-spin class on a div
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
|
||||
@@ -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')}
|
||||
@@ -1423,6 +1423,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading:
|
||||
)
|
||||
}
|
||||
|
||||
// ── Photo date grouping ───────────────────────────────────────────────────
|
||||
|
||||
function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
|
||||
const map = new Map<string, any[]>()
|
||||
for (const asset of photos) {
|
||||
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(asset)
|
||||
}
|
||||
return [...map.entries()].map(([date, assets]) => ({
|
||||
date,
|
||||
label: date === '__unknown__'
|
||||
? 'Unknown date'
|
||||
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
assets,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Provider Picker ───────────────────────────────────────────────────────
|
||||
|
||||
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||
@@ -1437,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)
|
||||
@@ -1500,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)
|
||||
@@ -1547,7 +1567,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<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="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-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
@@ -1625,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'
|
||||
@@ -1732,51 +1752,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
||||
{photos.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||
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`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{groupPhotosByDate(photos).map(group => (
|
||||
<div key={group.date}>
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
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${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
</div>
|
||||
@@ -2000,7 +2029,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.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<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="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2384,7 +2413,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<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="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-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2481,7 +2510,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
|
||||
}
|
||||
|
||||
return (
|
||||
<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="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-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2738,7 +2767,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
|
||||
@@ -151,6 +151,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
logout: () => {
|
||||
disconnect()
|
||||
useSystemNoticeStore.getState().reset()
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
|
||||
@@ -314,6 +314,47 @@ describe('journeyStore', () => {
|
||||
expect(storedEntry?.photos[0].id).toBe(201);
|
||||
});
|
||||
|
||||
// ── loadJourney silent refresh ───────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => {
|
||||
const existing = buildJourneyDetail({ id: 5, title: 'Old' });
|
||||
useJourneyStore.setState({ current: existing, loading: false });
|
||||
|
||||
const loadingValues: boolean[] = [];
|
||||
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
|
||||
|
||||
const refreshed = buildJourneyDetail({ id: 5, title: 'Refreshed' });
|
||||
server.use(
|
||||
http.get('/api/journeys/5', () => HttpResponse.json(refreshed))
|
||||
);
|
||||
|
||||
await useJourneyStore.getState().loadJourney(5);
|
||||
unsub();
|
||||
|
||||
expect(loadingValues.every(v => v === false)).toBe(true);
|
||||
expect(useJourneyStore.getState().current?.title).toBe('Refreshed');
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-017: loadJourney sets loading on cold load (different journey)', async () => {
|
||||
const existing = buildJourneyDetail({ id: 5 });
|
||||
useJourneyStore.setState({ current: existing, loading: false });
|
||||
|
||||
const loadingValues: boolean[] = [];
|
||||
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
|
||||
|
||||
const other = buildJourneyDetail({ id: 99 });
|
||||
server.use(
|
||||
http.get('/api/journeys/99', () => HttpResponse.json(other))
|
||||
);
|
||||
|
||||
await useJourneyStore.getState().loadJourney(99);
|
||||
unsub();
|
||||
|
||||
expect(loadingValues).toContain(true);
|
||||
expect(useJourneyStore.getState().current?.id).toBe(99);
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||
|
||||
@@ -124,7 +124,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadJourney: async (id) => {
|
||||
set({ loading: true, notFound: false })
|
||||
const cold = get().current?.id !== id
|
||||
if (cold) set({ loading: true, notFound: false })
|
||||
try {
|
||||
const data = await journeyApi.get(id)
|
||||
set({ current: data })
|
||||
@@ -134,7 +135,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
if (cold) set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface SystemNoticeState {
|
||||
fetching: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
dismiss: (id: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
|
||||
@@ -51,6 +52,10 @@ export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
set({ notices: [], loaded: false, fetching: false });
|
||||
},
|
||||
|
||||
dismiss(id: string) {
|
||||
// Optimistic: remove immediately
|
||||
const prev = get().notices;
|
||||
|
||||
@@ -1614,6 +1614,7 @@ function runMigrations(db: Database.Database): void {
|
||||
// Migration 102: Add check_in_end column for check-in time ranges
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 103: System notices — user tracking columns + dismissals table
|
||||
() => {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`);
|
||||
@@ -1628,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) {
|
||||
|
||||
@@ -60,16 +60,12 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to, size } = req.body;
|
||||
const { from, to, size, page } = req.body;
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.min(Number(size) || 50, 200);
|
||||
const allAssets: any[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.assets) allAssets.push(...result.assets);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
res.json({ assets: allAssets });
|
||||
const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ? AND je.journey_id = ?
|
||||
WHERE jp.photo_id = ? AND je.journey_id = ?
|
||||
`).get(photoId, row.journey_id) as any;
|
||||
if (!photo) return null;
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,9 +30,11 @@ function evaluateOne(condition: NoticeCondition, ctx: ConditionContext): boolean
|
||||
const userVersion = semver.valid(ctx.user.first_seen_version) ?? '0.0.0';
|
||||
const noticeVersion = semver.valid(condition.version);
|
||||
if (!noticeVersion) return false;
|
||||
// Strip prerelease/build metadata so '3.0.0-pre.42' is treated as '3.0.0'.
|
||||
const appVersion = semver.coerce(ctx.currentAppVersion)?.version ?? '0.0.0';
|
||||
return (
|
||||
semver.lt(userVersion, noticeVersion) &&
|
||||
semver.gte(semver.valid(ctx.currentAppVersion) ?? '0.0.0', noticeVersion)
|
||||
semver.gte(appVersion, noticeVersion)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,30 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
},
|
||||
|
||||
{
|
||||
// Page 3 — other highlights
|
||||
// Page 3 — MCP OAuth 2.1 upgrade (only when MCP addon is enabled)
|
||||
id: 'v3-mcp',
|
||||
display: 'modal',
|
||||
severity: 'warn',
|
||||
icon: 'Bot',
|
||||
titleKey: 'system_notice.v3_mcp.title',
|
||||
bodyKey: 'system_notice.v3_mcp.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_oauth', iconName: 'KeyRound' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_scopes', iconName: 'ShieldCheck' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_deprecated', iconName: 'AlertTriangle' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_tools', iconName: 'Wrench' },
|
||||
],
|
||||
dismissible: true,
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'mcp' },
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 75,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 4 — other highlights
|
||||
id: 'v3-features',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
|
||||
@@ -350,6 +350,7 @@ export interface TrekPhoto {
|
||||
thumbnail_path?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
passphrase?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -273,18 +273,19 @@ describe('Immich browse and search', () => {
|
||||
expect(res.body.buckets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('IMMICH-042 — POST /search returns mapped assets', async () => {
|
||||
it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
.send({ page: 1, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' });
|
||||
expect(typeof res.body.hasMore).toBe('boolean');
|
||||
});
|
||||
|
||||
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
|
||||
@@ -611,43 +612,77 @@ describe('Immich syncAlbumAssets', () => {
|
||||
|
||||
// ── searchPhotos pagination safety ────────────────────────────────────────────
|
||||
|
||||
describe('Immich searchPhotos pagination safety', () => {
|
||||
it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
|
||||
describe('Immich searchPhotos pagination pass-through', () => {
|
||||
it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Return a full page of 1000 items on every call, so the loop would
|
||||
// run indefinitely without the page > 20 safety check.
|
||||
// Return a full page so hasMore=true (items.length >= size)
|
||||
const fullPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `asset-${i}`,
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `asset-p2-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Paris', country: 'France' },
|
||||
exifInfo: { city: 'Berlin', country: 'Germany' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Clear previous call history so the count only reflects this test
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
.send({ page: 2, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
// 20 pages × 1000 items = 20000 assets total (safety limit)
|
||||
expect(res.body.assets.length).toBe(20000);
|
||||
// safeFetch should have been called exactly 20 times (the safety limit)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
|
||||
// Single page returned — not 20× aggregation
|
||||
expect(res.body.assets.length).toBe(50);
|
||||
expect(res.body.hasMore).toBe(true);
|
||||
// Immich was called exactly once
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1);
|
||||
// page=2 was forwarded to Immich
|
||||
const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string);
|
||||
expect(callBody.page).toBe(2);
|
||||
});
|
||||
|
||||
it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Partial page → hasMore=false
|
||||
const partialPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 3 }, (_, i) => ({
|
||||
id: `asset-last-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Rome', country: 'Italy' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
vi.mocked(safeFetch).mockResolvedValue(partialPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ page: 5, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assets.length).toBe(3);
|
||||
expect(res.body.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ afterAll(() => {
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
/** Insert a journey_photos row and return its id. */
|
||||
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
|
||||
function insertJourneyPhoto(
|
||||
entryId: number,
|
||||
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
||||
@@ -70,11 +70,13 @@ function insertJourneyPhoto(
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
|
||||
const trekId = trekResult.lastInsertRowid as number;
|
||||
const result = testDb.prepare(`
|
||||
testDb.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, NULL, 0, ?)
|
||||
`).run(entryId, trekId, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
// Return trek_photos.id — this is p.photo_id in the public API response
|
||||
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
|
||||
return trekId;
|
||||
}
|
||||
|
||||
// -- Tests --------------------------------------------------------------------
|
||||
@@ -237,6 +239,31 @@ describe('validateShareTokenForPhoto', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => {
|
||||
// Simulate a user who has many trek_photos from Immich syncs before adding a journey photo.
|
||||
// trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id
|
||||
// instead of jp.photo_id, causing a 404 for Immich photos in public shares.
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
||||
|
||||
// Pre-populate trek_photos to push the autoincrement higher
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testDb.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`).run(`bulk-asset-${i}`, user.id, Date.now());
|
||||
}
|
||||
|
||||
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
|
||||
const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
// photoId = trek_photos.id (6), not journey_photos.id (1)
|
||||
const result = validateShareTokenForPhoto(token, trekPhotoId);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
expect(result!.journeyId).toBe(journey.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateShareTokenForAsset', () => {
|
||||
|
||||
@@ -40,6 +40,12 @@ describe('existingUserBeforeVersion', () => {
|
||||
it('fails when current app version < notice version', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '1.5.0' })).toBe(false);
|
||||
});
|
||||
it('passes when current app version is a prerelease of the notice version', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '2.0.0-pre.42' })).toBe(true);
|
||||
});
|
||||
it('passes when current app version is a prerelease beyond the notice version', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '2.1.0-pre.1' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateWindow', () => {
|
||||
|
||||
Reference in New Issue
Block a user