Compare commits

...

5 Commits

Author SHA1 Message Date
jubnl ccea7f7a65 fix: restore map share toggle and fix public journey horizontal scroll
Re-adds the share_map permission toggle to the journey share settings UI so
owners can control whether the map is visible on the public share page.
Fixes horizontal scrollbar on the public journey page caused by decorative
hero circles with negative offsets overflowing the viewport.
2026-04-22 17:05:15 +02:00
jubnl 45a5b4e588 fix: remove obsolete map share toggle and make public desktop entries openable
Map permission is always enabled on new links (share always includes map).
Removed the toggle from the share settings UI since the map is now always
part of the combined timeline+map view with no standalone value in toggling it.

Desktop entry cards on the public share page now open MobileEntryView on click,
matching the mobile behaviour added in #826.
2026-04-22 16:33:04 +02:00
jubnl 82cce365f7 fix: validate image-only uploads and respect allowed_file_types setting for journey photos
Add fileFilter to the journey photo multer config (shared by entry photo
upload and gallery upload routes):
- Rejects any non-image MIME type (including SVG which carries XSS risk)
- Checks the extension against the admin-configured allowed_file_types setting
  (same getAllowedExtensions() used by the trip file upload route)
- Returns HTTP 400 with a descriptive message on rejection

Also fix the global error handler to return err.message for 4xx responses
instead of the generic 'Internal server error', so fileFilter rejections
produce a readable error on the client.
2026-04-22 16:16:35 +02:00
jubnl ed7e2badca fix: catch sharp errors in ensureLocalThumbnail and fall back to original
Sharp throws on unsupported formats (HEIC, corrupt files, etc.) and the
error was propagated outside the try/catch, crashing the server. Moved the
mkdir + sharp pipeline inside the catch block so any failure returns null
and streamPhoto falls through to serving the original file.
2026-04-22 16:11:38 +02:00
jubnl ba7b99fb7d fix: update backend tests and service bugs for gallery 1-to-N schema
updatePhoto: write sort_order to journey_entry_photos (junction) not journey_photos,
since JP_SELECT reads jep.sort_order — updating the gallery row had no visible effect.

deletePhoto: include id in return value so callers that check deleted.id still work.

Tests updated for new schema:
- journeyShareService: insertJourneyPhoto helper now inserts into journey_photos
  (keyed by journey_id) + journey_entry_photos junction instead of the old
  entry_id-keyed table
- SVC-081: deleteEntry cascades junction rows (journey_entry_photos), not gallery
  rows (journey_photos); assert junction is gone, gallery is preserved
- SVC-086: syncTripPhotos now populates the gallery directly — no [Trip Photos]
  wrapper entry; assert journey_photos gallery row instead
- INT-028: error message updated to 'journey_photo_id required'
2026-04-22 16:05:18 +02:00
8 changed files with 71 additions and 40 deletions
+2 -2
View File
@@ -317,7 +317,7 @@ export default function JourneyPublicPage() {
)}
{/* Content */}
<div className="px-5 pt-4 pb-5">
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
{/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
@@ -448,7 +448,7 @@ export default function JourneyPublicPage() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
{journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)}
+4 -2
View File
@@ -372,8 +372,10 @@ export function createApp(): express.Application {
} else {
console.error('Unhandled error:', err);
}
const status = err.statusCode || 500;
res.status(status).json({ error: 'Internal server error' });
const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
});
return app;
+18
View File
@@ -9,6 +9,7 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
const router = express.Router();
@@ -25,9 +26,26 @@ const storage = multer.diskStorage({
},
});
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: imageFilter,
});
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
+10 -12
View File
@@ -781,22 +781,20 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null;
const fields: string[] = [];
const values: unknown[] = [];
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
if (!fields.length) {
// no-op: return some photo row for this gallery item (first entry link)
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
// caption lives on the gallery row; sort_order lives on the junction table
// (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
// would not be reflected in the returned row).
if (data.caption !== undefined) {
db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
}
if (data.sort_order !== undefined) {
db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
}
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
}
// deletePhoto: hard-delete (backwards compat name used by old route).
export function deletePhoto(photoId: number, userId: number): { photo_id: number; file_path?: string | null; journey_id: number } | null {
export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null;
@@ -806,7 +804,7 @@ export function deletePhoto(photoId: number, userId: number): { photo_id: number
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(row.photo_id);
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
}
// ── Contributors ─────────────────────────────────────────────────────────
@@ -27,14 +27,17 @@ export async function ensureLocalThumbnail(
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
}
} catch { /* regenerate */ }
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
await sharp(originalAbs)
.rotate()
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
.webp({ quality: THUMB_QUALITY })
.toFile(thumbAbs)
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
await sharp(originalAbs)
.rotate()
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
.webp({ quality: THUMB_QUALITY })
.toFile(thumbAbs)
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
}
+1 -1
View File
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe('photo_id required');
expect(res.body.error).toBe('journey_photo_id required');
});
});
@@ -1325,9 +1325,10 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
// Photo should be deleted with the entry
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
expect(deletedPhoto).toBeUndefined();
// Junction row must be gone (ON DELETE CASCADE from journey_entries).
// Gallery row (journey_photos) is preserved — photo may belong to other entries.
const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
expect(junctionRow).toBeUndefined();
});
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1395,17 +1396,12 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id);
// Should have a [Trip Photos] entry with the imported photo
const photoEntry = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
// Trip photos now go straight into the journey gallery (no wrapper entry).
const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.entry_id = ?
`).all(photoEntry.id);
WHERE jp.journey_id = ?
`).all(journey.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
});
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_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,10 +70,24 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now());
`).run(journeyId, trekId, now);
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
testDb.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
// 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;