Merge remote-tracking branch 'origin/dev' into dev-maurice

# Conflicts:
#	client/src/components/Todo/TodoListPanel.tsx
#	server/src/db/migrations.ts
This commit is contained in:
Maurice
2026-04-17 23:44:53 +02:00
71 changed files with 2259 additions and 515 deletions
+49 -1
View File
@@ -1634,7 +1634,55 @@ function runMigrations(db: Database.Database): void {
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; }
},
// Migration 105: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
// Migration 105: Persistent Google place photo disk cache registry
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS google_place_photo_meta (
place_id TEXT PRIMARY KEY,
attribution TEXT,
fetched_at INTEGER NOT NULL,
error_at INTEGER
)
`);
},
// Migration 106: Persistent Place Details row cache
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS place_details_cache (
place_id TEXT NOT NULL,
lang TEXT NOT NULL DEFAULT '',
expanded INTEGER NOT NULL DEFAULT 0,
payload_json TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (place_id, lang, expanded)
)
`);
},
// Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs
{ raw: () => {
db.exec(`
UPDATE places
SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes',
updated_at = CURRENT_TIMESTAMP
WHERE google_place_id IS NOT NULL
AND image_url IS NOT NULL
AND image_url != ''
AND (
(image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%')
OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%')
)
`);
}},
// Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology)
() => db.exec(`
CREATE TABLE IF NOT EXISTS trek_photo_cache_meta (
cache_key TEXT PRIMARY KEY,
content_type TEXT NOT NULL DEFAULT 'image/jpeg',
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at);
`),
// Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_endpoints (
+1
View File
@@ -49,6 +49,7 @@ const server = app.listen(PORT, () => {
scheduler.startVersionCheck();
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
scheduler.startTrekPhotoCacheCleanup();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
import('./websocket').then(({ setupWebSocket }) => {
+57
View File
@@ -201,6 +201,63 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
res.json(result);
});
// ── Places Photos ───────────────────────────────────────────────────────
router.get('/places-photos', (_req: Request, res: Response) => {
res.json(svc.getPlacesPhotos());
});
router.put('/places-photos', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesPhotos(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_photos',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Autocomplete ──────────────────────────────────────────────────
router.get('/places-autocomplete', (_req: Request, res: Response) => {
res.json(svc.getPlacesAutocomplete());
});
router.put('/places-autocomplete', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesAutocomplete(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_autocomplete',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Details ───────────────────────────────────────────────────────
router.get('/places-details', (_req: Request, res: Response) => {
res.json(svc.getPlacesDetails());
});
router.put('/places-details', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesDetails(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_details',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Collab Features ───────────────────────────────────────────────────────
router.get('/collab-features', (_req: Request, res: Response) => {
+4 -3
View File
@@ -115,13 +115,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, asset_ids, caption } = req.body || {};
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
// Batch mode: { provider, asset_ids: string[] }
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
@@ -129,7 +130,7 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
// Single mode (backward compat)
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
+29 -1
View File
@@ -4,11 +4,14 @@ import { AuthRequest } from '../types';
import {
searchPlaces,
getPlaceDetails,
getPlaceDetailsExpanded,
getPlacePhoto,
reverseGeocode,
resolveGoogleMapsUrl,
autocompletePlaces,
} from '../services/mapsService';
import { db } from '../db/database';
import { serveFilePath } from '../services/placePhotoCache';
const router = express.Router();
@@ -32,6 +35,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
// POST /autocomplete
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' });
const authReq = req as AuthRequest;
const { input, lang, locationBias } = req.body;
@@ -70,11 +76,18 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
// GET /details/:placeId
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true });
const authReq = req as AuthRequest;
const { placeId } = req.params;
const expand = req.query.expand as string | undefined;
try {
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
const refresh = req.query.refresh === '1';
const result = expand
? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh)
: await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
res.json(result);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 500;
@@ -88,6 +101,12 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { placeId } = req.params;
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
if (!placeId.startsWith('coords:')) {
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
}
const lat = parseFloat(req.query.lat as string);
const lng = parseFloat(req.query.lng as string);
@@ -102,6 +121,15 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
}
});
// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk
router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => {
const { placeId } = req.params;
const fp = serveFilePath(placeId);
if (!fp) return res.status(404).json({ error: 'Photo not cached' });
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.sendFile(fp);
});
// GET /reverse
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
+27 -8
View File
@@ -166,14 +166,9 @@ function startTripReminders(): void {
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
const hasWebhook = activeChannels.includes('webhook');
const channelReady = hasEmail || hasWebhook;
if (!channelReady || !reminderEnabled) {
if (!reminderEnabled) {
const { logInfo: li } = require('./services/auditLog');
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
li(`Trip reminders: disabled (${reason})`);
li('Trip reminders: disabled in settings');
return;
}
@@ -253,12 +248,36 @@ function startIdempotencyCleanup(): void {
}, { timezone: tz });
}
// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL
let trekPhotoCacheTask: ScheduledTask | null = null;
function startTrekPhotoCacheCleanup(): void {
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
// Run once immediately on startup to evict any entries left over from a previous run
try {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
} catch { /* cache dir may not exist yet — harmless */ }
trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => {
try {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
}
});
}
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
+36
View File
@@ -459,6 +459,42 @@ export function updateBagTracking(enabled: boolean) {
return { enabled: !!enabled };
}
// ── Places Photos ─────────────────────────────────────────────────────────
export function getPlacesPhotos() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesPhotos(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Places Autocomplete ────────────────────────────────────────────────────
export function getPlacesAutocomplete() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesAutocomplete(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Places Details ─────────────────────────────────────────────────────────
export function getPlacesDetails() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesDetails(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Collab Features ───────────────────────────────────────────────────────
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
+11 -2
View File
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
'notify_trip_reminder',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
];
@@ -227,8 +228,13 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
const tripRemindersEnabled = tripReminderSetting !== 'false';
const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value;
const placesPhotosEnabled = placesPhotosSetting !== 'false';
const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value;
const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false';
const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value;
const placesDetailsEnabled = placesDetailsSetting !== 'false';
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
return {
@@ -258,6 +264,9 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
notification_channels: activeChannels,
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
trip_reminders_enabled: tripRemindersEnabled,
places_photos_enabled: placesPhotosEnabled,
places_autocomplete_enabled: placesAutocompleteEnabled,
places_details_enabled: placesDetailsEnabled,
permissions: authenticatedUser ? getAllPermissions() : undefined,
dev_mode: process.env.NODE_ENV === 'development',
};
+12 -7
View File
@@ -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();
@@ -59,12 +59,14 @@ export function listJourneys(userId: number) {
SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
FROM journeys j
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
WHERE j.user_id = ? OR jc.user_id = ?
ORDER BY j.updated_at DESC
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; place_count: number; trip_date_min: string | null; trip_date_max: string | null })[];
}
export function createJourney(userId: number, data: {
@@ -159,7 +161,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
@@ -170,7 +172,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
stats: { entries: entryCount, photos: photoCount, places: places.length },
hide_skeletons: !!(userPrefs?.hide_skeletons),
};
}
@@ -184,11 +186,13 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
}>): Journey | null {
if (!canEdit(journeyId, userId)) return null;
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && allowed.includes(key)) {
if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue;
fields.push(`${key} = ?`);
values.push(val);
}
@@ -628,12 +632,12 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
@@ -714,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);
+1 -1
View File
@@ -132,7 +132,7 @@ export function getPublicJourney(token: string) {
const stats = {
entries: entries.length,
photos: photos.length,
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
return {
+186 -89
View File
@@ -2,6 +2,19 @@ import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard';
// ── Google API call counter ───────────────────────────────────────────────────
let googleApiCallCount = 0;
export function getGoogleApiCallCount(): number { return googleApiCallCount; }
export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
googleApiCallCount++;
console.debug(`[Google API] #${googleApiCallCount} ${label}${endpoint}`);
return fetch(endpoint, init);
}
// ── Interfaces ───────────────────────────────────────────────────────────────
interface NominatimResult {
@@ -55,26 +68,8 @@ interface GooglePlaceDetails extends GooglePlaceResult {
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
// ── Photo cache ──────────────────────────────────────────────────────────────
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of photoCache) {
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
}
if (photoCache.size > CACHE_MAX_ENTRIES) {
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => photoCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
// ── API key retrieval ────────────────────────────────────────────────────────
@@ -311,7 +306,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
return { places, source: 'openstreetmap' };
}
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -371,7 +366,7 @@ export async function autocompletePlaces(
};
}
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -451,12 +446,79 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
}
// Google details
const langKey = lang || 'de';
const apiKey = getMapsKey(userId);
if (!apiKey) {
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
}
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
const cached = db.prepare(
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
},
});
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
}
const place = {
google_place_id: data.id,
name: data.displayName?.text || '',
address: data.formattedAddress || '',
lat: data.location?.latitude || null,
lng: data.location?.longitude || null,
rating: data.rating || null,
rating_count: data.userRatingCount || null,
website: data.websiteUri || null,
phone: data.nationalPhoneNumber || null,
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
open_now: data.regularOpeningHours?.openNow ?? null,
google_maps_url: data.googleMapsUri || null,
summary: null,
reviews: [],
source: 'google' as const,
cached_at: Date.now(),
};
try {
db.prepare(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
).run(placeId, langKey, JSON.stringify(place), Date.now());
} catch (dbErr) {
console.error('Failed to cache place details:', dbErr);
}
return { place };
}
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
const langKey = lang || 'de';
const apiKey = getMapsKey(userId);
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
// Check DB cache for expanded result
if (!refresh) {
const cached = db.prepare(
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
).get(placeId, langKey) as { payload_json: string } | undefined;
if (cached) return { place: JSON.parse(cached.payload_json) };
}
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
@@ -494,12 +556,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
photo: r.authorAttribution?.photoUri || null,
})),
source: 'google' as const,
cached_at: Date.now(),
};
try {
db.prepare(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
).run(placeId, langKey, JSON.stringify(place), Date.now());
} catch (dbErr) {
console.error('Failed to cache expanded place details:', dbErr);
}
return { place };
}
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
// ── Place photo (Google or Wikimedia, disk-cached) ────────────────────────────
export async function getPlacePhoto(
userId: number,
@@ -508,84 +579,110 @@ export async function getPlacePhoto(
lng: number,
name?: string,
): Promise<{ photoUrl: string; attribution: string | null }> {
// Check cache first
const cached = photoCache.get(placeId);
if (cached) {
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
if (Date.now() - cached.fetchedAt < ttl) {
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
// Disk cache hit — serve immediately, no Google call
const diskHit = placePhotoCache.get(placeId);
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
// Recent error — don't hammer the API
if (placePhotoCache.getErrored(placeId)) {
throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
}
// Deduplicate concurrent requests for the same placeId
const existing = placePhotoCache.getInFlight(placeId);
if (existing) {
const result = await existing;
if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (wiki) {
// Wikimedia photos: fetch bytes and cache to disk
const ssrf = await checkSsrf(wiki.photoUrl, true);
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
const imgRes = await fetch(wiki.photoUrl);
if (imgRes.ok) {
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
}
}
} catch { /* fall through */ }
}
placePhotoCache.markError(placeId);
return null;
}
photoCache.delete(placeId);
}
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// Google Photos — fetch details to get photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
// No Google key or coordinate-only lookup -> try Wikimedia
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (wiki) {
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
return wiki;
} else {
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
}
} catch { /* fall through */ }
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
placePhotoCache.markError(placeId);
return null;
}
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
}
// Google Photos
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!details.photos?.length) {
placePhotoCache.markError(placeId);
return null;
}
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
if (!details.photos?.length) {
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
}
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
if (!mediaRes.ok) {
placePhotoCache.markError(placeId);
return null;
}
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
const mediaData = await mediaRes.json() as { photoUri?: string };
const photoUrl = mediaData.photoUri;
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) {
placePhotoCache.markError(placeId);
return null;
}
if (!photoUrl) {
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
}
const cached = await placePhotoCache.put(placeId, bytes, attribution);
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
// Persist stable proxy URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
// Persist photo URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
).run(photoUrl, placeId, '');
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { filePath: cached.filePath, attribution };
})();
return { photoUrl, attribution };
placePhotoCache.setInFlight(placeId, fetchPromise);
const result = await fetchPromise;
if (!result) throw Object.assign(new Error('No photo available'), { status: 404 });
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
// ── Reverse geocoding ────────────────────────────────────────────────────────
@@ -230,6 +230,30 @@ export async function getAssetInfo(
}
}
export async function fetchImmichThumbnailBytes(
userId: number,
assetId: string,
ownerUserId?: number
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
try {
const resp = await safeFetch(url, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000) as any,
});
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
return { bytes, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function streamImmichAsset(
response: Response,
userId: number,
@@ -3,11 +3,12 @@ import path from 'path';
import fs from 'fs';
import { db } from '../../db/database';
import type { TrekPhoto } from '../../types';
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService';
import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService';
import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import * as photoCache from './trekPhotoCache';
// ── Lookup / Register ────────────────────────────────────────────────────
@@ -22,7 +23,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;
@@ -57,6 +58,36 @@ export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
// ── Streaming ────────────────────────────────────────────────────────────
async function streamCachedThumbnail(
res: Response,
photo: TrekPhoto,
fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>,
fallback: () => Promise<unknown>,
): Promise<void> {
const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!);
if (photoCache.serveFresh(res, key)) return;
const existing = photoCache.getInFlight(key);
if (existing) {
const bytes = await existing;
if (bytes && photoCache.serveFresh(res, key)) return;
await fallback();
return;
}
const promise = fetchBytes().then(async result => {
if ('error' in result) return null;
await photoCache.put(key, result.bytes, result.contentType);
return result.bytes;
});
photoCache.setInFlight(key, promise);
const bytes = await promise;
if (bytes && photoCache.serveFresh(res, key)) return;
await fallback();
}
export async function streamPhoto(
res: Response,
userId: number,
@@ -84,11 +115,27 @@ export async function streamPhoto(
return;
}
case 'immich': {
if (kind === 'thumbnail') {
await streamCachedThumbnail(
res, photo,
() => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!),
() => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!),
);
return;
}
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
return;
}
case 'synologyphotos': {
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
if (kind === 'thumbnail') {
await streamCachedThumbnail(
res, photo,
() => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase),
() => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase),
);
return;
}
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
return;
}
@@ -145,6 +192,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 {
+55 -21
View File
@@ -604,6 +604,47 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ
return success(normalized);
}
export async function fetchSynologyThumbnailBytes(
userId: number,
targetUserId: number,
photoId: string,
passphrase?: string,
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
const synology_credentials = _getSynologyCredentials(targetUserId);
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
const sid = await _getSynologySession(targetUserId);
if (!sid.success) return { error: 'Session error', status: 500 };
if (!sid.data) return { error: 'Session ID missing', status: 500 };
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: 'sm',
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
try {
const resp = await safeFetch(url);
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
return { bytes, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function streamSynologyAsset(
response: Response,
userId: number,
@@ -637,27 +678,20 @@ 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',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
: new URLSearchParams({
api: 'SYNO.Foto.Download',
method: 'download',
version: '2',
cache_key: parsedId.cacheKey,
unit_id: `[${parsedId.id}]`,
_sid: sid.data,
});
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render)
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
@@ -0,0 +1,91 @@
import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import crypto from 'node:crypto';
import { Response } from 'express';
import { db } from '../../db/database';
const TREK_PHOTO_DIR = path.join(__dirname, '../../../uploads/photos/trek');
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
const inFlight = new Map<string, Promise<Buffer | null>>();
export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
}
function ensureDir(): void {
if (!fs.existsSync(TREK_PHOTO_DIR)) {
fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true });
}
}
function cachedFilePath(key: string): string {
return path.join(TREK_PHOTO_DIR, `${key}.bin`);
}
export function getFresh(key: string): { filePath: string; contentType: string } | null {
const row = db.prepare(
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?'
).get(key) as { content_type: string; fetched_at: number } | undefined;
if (!row) return null;
if (Date.now() - row.fetched_at >= CACHE_TTL) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
return null;
}
const fp = cachedFilePath(key);
if (!fs.existsSync(fp)) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
return null;
}
return { filePath: fp, contentType: row.content_type };
}
export async function put(key: string, bytes: Buffer, contentType: string): Promise<void> {
ensureDir();
const fp = cachedFilePath(key);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
await fsPromises.rename(tmp, fp);
db.prepare(
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)'
).run(key, contentType, Date.now());
}
export function serveFresh(res: Response, key: string): boolean {
const entry = getFresh(key);
if (!entry) return false;
res.set('Content-Type', entry.contentType);
res.set('Cache-Control', 'public, max-age=3600');
res.sendFile(entry.filePath);
return true;
}
export function getInFlight(key: string): Promise<Buffer | null> | undefined {
return inFlight.get(key);
}
export function setInFlight(key: string, promise: Promise<Buffer | null>): void {
inFlight.set(key, promise);
promise.finally(() => inFlight.delete(key));
}
export function sweepExpired(): void {
const cutoff = Date.now() - CACHE_TTL * 2;
const stale = db.prepare(
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?'
).all(cutoff) as { cache_key: string }[];
for (const row of stale) {
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key);
const fp = cachedFilePath(row.cache_key);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
}
+10 -2
View File
@@ -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,13 +270,20 @@ 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);
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.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');
+95
View File
@@ -0,0 +1,95 @@
import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import crypto from 'node:crypto';
import { db } from '../db/database';
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
const ERROR_TTL = 5 * 60 * 1000;
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
function ensureDir(): void {
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
}
}
function filePath(placeId: string): string {
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
// collapse identically under sanitization (e.g. ':' and '.' both → '_')
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`);
}
function proxyUrl(placeId: string): string {
return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
}
interface CachedPhoto {
photoUrl: string;
filePath: string;
attribution: string | null;
}
export function get(placeId: string): CachedPhoto | null {
const row = db.prepare(
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
).get(placeId) as { attribution: string | null } | undefined;
if (!row) return null;
const fp = filePath(placeId);
if (!fs.existsSync(fp)) {
// File missing (e.g. volume wiped) — clear row so it refetches
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
return null;
}
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
}
export function getErrored(placeId: string): boolean {
const row = db.prepare(
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
).get(placeId) as { error_at: number } | undefined;
if (!row) return false;
return Date.now() - row.error_at < ERROR_TTL;
}
export function markError(placeId: string): void {
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
).run(placeId, Date.now(), Date.now());
}
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
ensureDir();
const fp = filePath(placeId);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
await fsPromises.rename(tmp, fp);
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
).run(placeId, attribution, Date.now());
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
}
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
return inFlight.get(placeId);
}
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
inFlight.set(placeId, promise);
promise.finally(() => inFlight.delete(placeId));
}
export function serveFilePath(placeId: string): string | null {
const fp = filePath(placeId);
return fs.existsSync(fp) ? fp : null;
}
+10
View File
@@ -27,6 +27,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 90,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -55,6 +57,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 80,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -78,6 +82,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 75,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -98,6 +104,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 70,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -112,6 +120,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 95,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
// ── Onboarding ─────────────────────────────────────────────────────────────
+28 -4
View File
@@ -1,10 +1,34 @@
import { createRequire } from 'module';
import semver from 'semver';
import { db } from '../db/database.js';
import { SYSTEM_NOTICES } from './registry.js';
import { evaluate } from './conditions.js';
import type { SystemNoticeDTO } from './types.js';
import type { SystemNotice, SystemNoticeDTO } from './types.js';
function getCurrentAppVersion(): string {
return process.env.APP_VERSION || '0.0.0';
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
if (fromEnv) return fromEnv;
try {
const pkg = require('../../package.json') as { version?: string };
return semver.valid(pkg.version ?? '') ?? '0.0.0';
} catch {
return '0.0.0';
}
}
export function isNoticeVersionActive(n: SystemNotice, currentAppVersion: string): boolean {
const appVersion = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
if (n.minVersion !== undefined) {
const min = semver.valid(n.minVersion);
if (!min) { console.warn(`[systemNotices] "${n.id}" invalid minVersion "${n.minVersion}" — skipping`); return false; }
if (semver.lt(appVersion, min)) return false;
}
if (n.maxVersion !== undefined) {
const max = semver.valid(n.maxVersion);
if (!max) { console.warn(`[systemNotices] "${n.id}" invalid maxVersion "${n.maxVersion}" — skipping`); return false; }
if (semver.gte(appVersion, max)) return false;
}
return true;
}
function severityWeight(s: string): number {
@@ -35,7 +59,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
return SYSTEM_NOTICES
.filter(n => {
if (dismissedIds.has(n.id)) return false;
if (n.expiresAt && now > new Date(n.expiresAt)) return false;
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
return evaluate(n, ctx);
})
.sort((a, b) => {
@@ -45,7 +69,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
if (sw !== 0) return sw;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
})
.map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
}
export function dismissNotice(userId: number, noticeId: string): boolean {
+3 -2
View File
@@ -37,9 +37,10 @@ export interface SystemNotice {
dismissible: boolean;
conditions: NoticeCondition[];
publishedAt: string;
expiresAt?: string;
minVersion?: string;
maxVersion?: string;
priority?: number;
}
// DTO sent to client (same shape minus the conditions — server evaluates those)
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;
+1 -1
View File
@@ -329,7 +329,7 @@ export interface Journey {
subtitle?: string | null;
cover_gradient?: string | null;
cover_image?: string | null;
status: 'draft' | 'active' | 'completed';
status: 'draft' | 'active' | 'completed' | 'archived';
created_at: number;
updated_at: number;
}