mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(synology): correct multi-album passphrase assignment and stale trek_photos
- ProviderPicker now tracks per-asset album passphrase in a Map; on confirm, assets are grouped by passphrase and submitted as separate batches so each asset receives its own album's passphrase instead of the last-selected one - getOrCreateTrekPhoto unconditionally overwrites the stored passphrase when a fresh one is supplied, allowing re-adds to heal a stuck bad passphrase - deleteTrekPhotoIfOrphan purges the trek_photos row for provider assets when no trip_photos or journey_photos reference it anymore; wired into removeTripPhoto, removeAlbumLink, and deletePhoto so remove + re-add is a clean slate - Three new integration tests: SYNO-090 (passphrase overwrite), SYNO-091 (orphan cleanup), SYNO-092 (remove + re-add restores correct passphrase)
This commit is contained in:
@@ -1042,7 +1042,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
trips={trips}
|
||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (assetIds, entryId, passphrase) => {
|
||||
onAdd={async (groups, entryId) => {
|
||||
let targetId = entryId
|
||||
if (!targetId) {
|
||||
try {
|
||||
@@ -1055,10 +1055,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
} catch { return }
|
||||
}
|
||||
let added = 0
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds, undefined, passphrase)
|
||||
added = result.added || 0
|
||||
} catch {}
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
added += result.added || 0
|
||||
} catch {}
|
||||
}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
@@ -1532,7 +1534,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (assetIds: string[], entryId: number | null, passphrase?: string) => Promise<void>
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
@@ -1546,7 +1548,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
@@ -1638,8 +1640,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
const next = new Map(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -1801,9 +1807,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set())
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Set(selectable.map((a: any) => a.id)))
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
@@ -1905,7 +1911,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd([...selected], targetEntryId, selectedAlbumPassphrase)}
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, string[]>()
|
||||
for (const [assetId, { passphrase }] of selected.entries()) {
|
||||
const list = groupMap.get(passphrase) || []
|
||||
list.push(assetId)
|
||||
groupMap.set(passphrase, list)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider, deleteTrekPhotoIfOrphan } from './memories/photoResolverService';
|
||||
|
||||
function ts(): number {
|
||||
return Date.now();
|
||||
@@ -718,6 +718,7 @@ export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & {
|
||||
if (!canEdit(photo.journey_id, userId)) return null;
|
||||
|
||||
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
||||
deleteTrekPhotoIfOrphan(photo.photo_id);
|
||||
|
||||
// clean up empty Gallery entries left behind
|
||||
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function getOrCreateTrekPhoto(
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
if (passphrase) {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?')
|
||||
.run(encrypt_api_key(passphrase), existing.id);
|
||||
}
|
||||
return existing.id;
|
||||
@@ -145,6 +145,19 @@ export function setTrekPhotoProvider(
|
||||
).run(provider, assetId, ownerId, trekPhotoId);
|
||||
}
|
||||
|
||||
// ── Orphan cleanup ───────────────────────────────────────────────────────
|
||||
|
||||
export function deleteTrekPhotoIfOrphan(photoId: number): void {
|
||||
const stillUsed = db.prepare(`
|
||||
SELECT 1 FROM trip_photos WHERE photo_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_photos WHERE photo_id = ?
|
||||
LIMIT 1
|
||||
`).get(photoId, photoId);
|
||||
if (stillUsed) return;
|
||||
db.prepare("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'").run(photoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
mapDbError,
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from './photoResolverService';
|
||||
import { encrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ export function removeTripPhoto(
|
||||
AND photo_id = ?
|
||||
`).run(tripId, userId, photoId);
|
||||
|
||||
deleteTrekPhotoIfOrphan(photoId);
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
|
||||
return success(true);
|
||||
@@ -269,6 +270,9 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
|
||||
}
|
||||
|
||||
try {
|
||||
const linkedPhotos = db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
|
||||
.all(tripId, linkId) as Array<{ photo_id: number }>;
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
|
||||
.run(tripId, linkId);
|
||||
@@ -276,6 +280,10 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
|
||||
.run(linkId, tripId, userId);
|
||||
})();
|
||||
|
||||
for (const { photo_id } of linkedPhotos) {
|
||||
deleteTrekPhotoIfOrphan(photo_id);
|
||||
}
|
||||
|
||||
return success(true);
|
||||
} catch (error) {
|
||||
return mapDbError(error, 'Failed to remove album link');
|
||||
|
||||
@@ -1169,3 +1169,74 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
expect(res.body.albums.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Passphrase persistence fixes ─────────────────────────────────────────────
|
||||
|
||||
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from '../../src/services/memories/photoResolverService';
|
||||
import { decrypt_api_key } from '../../src/services/apiKeyCrypto';
|
||||
|
||||
describe('trek_photos passphrase healing (SYNO-090)', () => {
|
||||
it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const wrongPass = 'wrong-passphrase';
|
||||
const correctPass = 'correct-passphrase';
|
||||
|
||||
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
|
||||
const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string };
|
||||
expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass);
|
||||
|
||||
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
|
||||
expect(id2).toBe(id1);
|
||||
const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
|
||||
expect(decrypt_api_key(row2.passphrase)).toBe(correctPass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trek_photos orphan cleanup (SYNO-091)', () => {
|
||||
it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
|
||||
const trekPhotoId = getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');
|
||||
|
||||
testDb.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
|
||||
).run(trip.id, user.id, trekPhotoId);
|
||||
|
||||
// Still referenced — must not be deleted.
|
||||
deleteTrekPhotoIfOrphan(trekPhotoId);
|
||||
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined();
|
||||
|
||||
// Remove the reference, then orphan-cleanup should delete the trek_photos row.
|
||||
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId);
|
||||
deleteTrekPhotoIfOrphan(trekPhotoId);
|
||||
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
|
||||
const firstPass = 'first-passphrase';
|
||||
const secondPass = 'second-passphrase';
|
||||
|
||||
// Add with wrong passphrase, then remove (simulating the bug scenario).
|
||||
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
|
||||
testDb.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
|
||||
).run(trip.id, user.id, id1);
|
||||
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1);
|
||||
deleteTrekPhotoIfOrphan(id1);
|
||||
|
||||
// trek_photos row should be gone.
|
||||
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined();
|
||||
|
||||
// Re-add with the correct passphrase.
|
||||
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
|
||||
expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user