mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
71aa8f8051
Replaces the old model where journey_photos was keyed per-entry with a per-journey gallery table (one row per unique photo per journey) and a new junction table journey_entry_photos that links gallery photos to entries. Key changes: - Migration 121: renames old journey_photos to journey_photos_old, creates the new gallery table + junction table, backfills both from existing data, drops the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries - journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos, addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts directly into the gallery instead of a wrapper entry - journeyShareService: updates public photo and asset validation queries to join through the gallery table instead of entry_id; getPublicJourney now returns a dedicated gallery array alongside per-entry photos - journey routes: adds gallery upload, provider-photo, and delete endpoints (POST/DELETE /:id/gallery/*); adds unlink-from-entry route (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to accept journey_photo_id with a backwards-compat photo_id alias - types: adds GalleryPhoto interface - client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto, deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId - journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail, uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions - JourneyDetailPage + tests: updated to work with the new gallery model
286 lines
9.5 KiB
TypeScript
286 lines
9.5 KiB
TypeScript
import { pipeline } from 'node:stream/promises';
|
|
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
|
|
|
|
type ServiceError = { success: false; error: { message: string; status: number } };
|
|
export type ServiceResult<T> = { success: true; data: T } | ServiceError;
|
|
|
|
|
|
export function fail(error: string, status: number): ServiceError {
|
|
return { success: false, error: { message: error, status } };
|
|
}
|
|
|
|
|
|
export function success<T>(data: T): ServiceResult<T> {
|
|
return { success: true, data: data };
|
|
}
|
|
|
|
|
|
export function mapDbError(error: Error, fallbackMessage: string): ServiceError {
|
|
if (error && /unique|constraint/i.test(error.message)) {
|
|
return fail('Resource already exists', 409);
|
|
}
|
|
return fail(error.message, 500);
|
|
}
|
|
|
|
|
|
export function handleServiceResult<T>(res: Response, result: ServiceResult<T>): void {
|
|
if ('error' in result) {
|
|
res.status(result.error.status).json({ error: result.error.message });
|
|
}
|
|
else {
|
|
res.json(result.data);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------
|
|
// types used across memories services
|
|
export type Selection = {
|
|
provider: string;
|
|
asset_ids: string[];
|
|
passphrase?: string;
|
|
};
|
|
|
|
export type StatusResult = {
|
|
connected: true;
|
|
user: { name: string }
|
|
} | {
|
|
connected: false;
|
|
error: string
|
|
};
|
|
|
|
export type SyncAlbumResult = {
|
|
added: number;
|
|
total: number
|
|
};
|
|
|
|
|
|
export type AlbumsList = {
|
|
albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
|
};
|
|
|
|
export type Asset = {
|
|
id: string;
|
|
takenAt: string;
|
|
};
|
|
|
|
export type AssetsList = {
|
|
assets: Asset[],
|
|
total: number,
|
|
hasMore: boolean
|
|
};
|
|
|
|
|
|
export type AssetInfo = {
|
|
id: string;
|
|
takenAt: string | null;
|
|
city: string | null;
|
|
country: string | null;
|
|
state?: string | null;
|
|
camera?: string | null;
|
|
lens?: string | null;
|
|
focalLength?: string | number | null;
|
|
aperture?: string | number | null;
|
|
shutter?: string | number | null;
|
|
iso?: string | number | null;
|
|
lat?: number | null;
|
|
lng?: number | null;
|
|
orientation?: number | null;
|
|
description?: string | null;
|
|
width?: number | null;
|
|
height?: number | null;
|
|
fileSize?: number | null;
|
|
fileName?: string | null;
|
|
}
|
|
|
|
|
|
//for loading routes to settings page, and validating which services user has connected
|
|
type PhotoProviderConfig = {
|
|
settings_get: string;
|
|
settings_put: string;
|
|
status_get: string;
|
|
test_post: string;
|
|
};
|
|
|
|
|
|
export function getPhotoProviderConfig(providerId: string): PhotoProviderConfig {
|
|
const prefix = `/integrations/memories/${providerId}`;
|
|
return {
|
|
settings_get: `${prefix}/settings`,
|
|
settings_put: `${prefix}/settings`,
|
|
status_get: `${prefix}/status`,
|
|
test_post: `${prefix}/test`,
|
|
};
|
|
}
|
|
|
|
//-----------------------------------------------
|
|
//access check helper
|
|
|
|
export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
|
|
if (requestingUserId === ownerUserId) {
|
|
return true;
|
|
}
|
|
|
|
// Journey photos use tripId=0 — check journey_photos + journey_contributors
|
|
if (tripId === '0') {
|
|
const journeyPhoto = db.prepare(`
|
|
SELECT gp.journey_id
|
|
FROM journey_photos gp
|
|
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
|
WHERE tkp.asset_id = ?
|
|
AND tkp.provider = ?
|
|
AND tkp.owner_id = ?
|
|
LIMIT 1
|
|
`).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
|
|
if (!journeyPhoto) return false;
|
|
|
|
const access = db.prepare(`
|
|
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
|
|
UNION ALL
|
|
SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
|
|
LIMIT 1
|
|
`).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
|
|
return !!access;
|
|
}
|
|
|
|
// Regular trip photos — join through trek_photos
|
|
const sharedAsset = db.prepare(`
|
|
SELECT 1
|
|
FROM trip_photos tp
|
|
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
|
WHERE tp.user_id = ?
|
|
AND tkp.asset_id = ?
|
|
AND tkp.provider = ?
|
|
AND tp.trip_id = ?
|
|
AND tp.shared = 1
|
|
LIMIT 1
|
|
`).get(ownerUserId, assetId, provider, tripId);
|
|
|
|
if (!sharedAsset) {
|
|
return false;
|
|
}
|
|
return !!canAccessTrip(tripId, requestingUserId);
|
|
}
|
|
|
|
|
|
// ── Unified photo access check (trek_photos based) ──────────────────────
|
|
|
|
export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
|
|
const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined;
|
|
if (!photo) return false;
|
|
|
|
// Owner always has access
|
|
if (photo.owner_id === requestingUserId) return true;
|
|
|
|
// Check trip_photos — is this photo shared in a trip the user has access to?
|
|
const tripAccess = db.prepare(`
|
|
SELECT 1 FROM trip_photos tp
|
|
WHERE tp.photo_id = ?
|
|
AND tp.shared = 1
|
|
AND EXISTS (
|
|
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
|
|
UNION ALL
|
|
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
|
|
)
|
|
LIMIT 1
|
|
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
|
if (tripAccess) return true;
|
|
|
|
// Check journey_photos — is this photo in a journey the user can access?
|
|
const journeyAccess = db.prepare(`
|
|
SELECT 1 FROM journey_photos gp
|
|
WHERE gp.photo_id = ?
|
|
AND EXISTS (
|
|
SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
|
|
UNION ALL
|
|
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
|
|
)
|
|
LIMIT 1
|
|
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
|
if (journeyAccess) return true;
|
|
|
|
// Local photos without owner (uploaded files) — check if user has journey access
|
|
if (photo.provider === 'local' && !photo.owner_id) {
|
|
return !!journeyAccess;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
// ----------------------------------------------
|
|
//helpers for album link syncing
|
|
|
|
export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<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 FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
|
.get(linkId, tripId, userId) as { album_id: string } | null;
|
|
|
|
return row ? success(row.album_id) : fail('Album link not found', 404);
|
|
} catch {
|
|
return fail('Failed to retrieve album link', 500);
|
|
}
|
|
}
|
|
|
|
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, 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.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);
|
|
|
|
if (!resp.body) {
|
|
response.end();
|
|
} else {
|
|
await pipeline(Readable.fromWeb(resp.body as any), response);
|
|
}
|
|
} catch (error) {
|
|
if (response.headersSent) {
|
|
response.end();
|
|
return;
|
|
}
|
|
if (error instanceof SsrfBlockedError) {
|
|
response.status(400).json({ error: error.message });
|
|
} else {
|
|
response.status(500).json({ error: 'Failed to fetch asset' });
|
|
}
|
|
}
|
|
} |