diff --git a/client/src/components/Files/FileManagerImageLightbox.tsx b/client/src/components/Files/FileManagerImageLightbox.tsx
index f349bf33..5492d412 100644
--- a/client/src/components/Files/FileManagerImageLightbox.tsx
+++ b/client/src/components/Files/FileManagerImageLightbox.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { ExternalLink, Download, X, ChevronLeft, ChevronRight } from 'lucide-react'
+import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TripFile } from '../../types'
import { getAuthUrl } from '../../api/authUrl'
@@ -126,14 +126,20 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
}
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
+ const fileIsVideo = isVideo(file.mime_type)
const [src, setSrc] = useState('')
- useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
+ // Videos have no stored thumbnail and can't render as an
; show a play
+ // placeholder and don't mint a download token for them (#823).
+ useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo])
return (
)
}
diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx
index 61ea781b..50162a61 100644
--- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx
+++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx
@@ -163,12 +163,18 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)}
>
-
+ {photo.media_type === 'video' && !photo.thumbnail_path ? (
+ // Poster-less video (capture failed / unsupported codec): show a
+ // neutral tile rather than a broken 404 thumbnail (#823).
+
+ ) : (
+
+ )}
{photo.media_type === 'video' && (
diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx
index d4464637..6e4e8957 100644
--- a/client/src/pages/JourneyPublicPage.tsx
+++ b/client/src/pages/JourneyPublicPage.tsx
@@ -1,7 +1,7 @@
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import {
- List, Grid, MapPin, Camera, BookOpen, Image, Clock,
+ List, Grid, MapPin, Camera, BookOpen, Image, Clock, Play,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
@@ -123,7 +123,7 @@ export default function JourneyPublicPage() {
const prosArr = entry.pros_cons?.pros ?? []
const consArr = entry.pros_cons?.cons ?? []
const hasProscons = prosArr.length > 0 || consArr.length > 0
- const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
+ const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type }))
const isActive = activeEntryId === String(entry.id)
return (
@@ -296,10 +296,17 @@ export default function JourneyPublicPage() {
{allPhotos.map((photo, idx) => (
setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
+ className="relative aspect-square rounded-lg overflow-hidden cursor-pointer"
+ onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type })), index: idx })}
>

+ {(photo as any).media_type === 'video' && (
+
+ )}
))}
@@ -513,6 +520,7 @@ export default function JourneyPublicPage() {
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
+ mediaType: (p as any).media_type,
})),
index: idx,
})}
diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts
index 3dae25b0..656bc5a0 100644
--- a/client/src/pages/journeyPublic/useJourneyPublic.ts
+++ b/client/src/pages/journeyPublic/useJourneyPublic.ts
@@ -23,7 +23,7 @@ export function useJourneyPublic() {
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
- const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
+ const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef(null)
diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts
index 922dbb4f..c8a7b63a 100644
--- a/server/src/nest/files/files.controller.ts
+++ b/server/src/nest/files/files.controller.ts
@@ -24,7 +24,7 @@ import type { User } from '../../types';
import { FilesService } from './files.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
-import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, getAllowedExtensions, isVideoExtension, isVideoMime } from '../../services/fileService';
+import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, getAllowedExtensions, isVideoExtension } from '../../services/fileService';
import { isDemoEmail } from '../../services/demo';
const UPLOAD = {
@@ -99,22 +99,39 @@ export class FilesController {
@Body() body: { place_id?: string; description?: string; reservation_id?: string },
@Headers('x-socket-id') socketId?: string,
) {
- const trip = this.requireTrip(tripId, user);
- if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
- throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
- }
- if (!this.files.can('file_upload', trip, user)) {
- throw new HttpException({ error: 'No permission to upload files' }, 403);
+ // multer (diskStorage) has already written the upload by the time we get here,
+ // so every rejection below must remove the orphaned bytes — otherwise a 404/403
+ // leaves up to the 500 MB video cap on disk (#823).
+ const cleanup = () => { if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } };
+ try {
+ const trip = this.requireTrip(tripId, user);
+ if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
+ throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
+ }
+ if (!this.files.can('file_upload', trip, user)) {
+ throw new HttpException({ error: 'No permission to upload files' }, 403);
+ }
+ } catch (err) {
+ cleanup();
+ throw err;
}
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
- // Only video may use the larger cap; other types stay at MAX_FILE_SIZE (#823).
- if (!isVideoMime(file.mimetype) && file.size > MAX_FILE_SIZE) {
- try { fs.unlinkSync(file.path); } catch { /* best-effort cleanup */ }
+ // The per-type cap is keyed on the EXTENSION, matching how the fileFilter
+ // decides acceptance — so a real video labelled application/octet-stream isn't
+ // wrongly rejected, and the 500 MB cap only applies to actual video extensions.
+ const isVideoUpload = isVideoExtension(path.extname(file.originalname || ''));
+ if (!isVideoUpload && file.size > MAX_FILE_SIZE) {
+ cleanup();
throw new HttpException({ error: 'File is too large' }, 400);
}
- this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
+ try {
+ this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
+ } catch (err) {
+ cleanup();
+ throw err;
+ }
const created = this.files.createFile(tripId, file, user.id, {
place_id: body.place_id,
description: body.description,
diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts
index 7d96bd71..48cc11ad 100644
--- a/server/src/nest/journey/journey.controller.ts
+++ b/server/src/nest/journey/journey.controller.ts
@@ -57,7 +57,15 @@ const IMAGE_UPLOAD = {
const VIDEO_UPLOAD = {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
- filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || (file.fieldname === 'poster' ? '.jpg' : '.mp4')}`),
+ filename: (_req, file, cb) => {
+ // The poster is ALWAYS stored as .jpg, never the client-supplied extension:
+ // otherwise a poster declared image/* but named x.html / x.js would land on
+ // disk with that extension and be served inline same-origin (stored XSS,
+ // reachable via the public share proxy). The video extension is validated by
+ // the fileFilter, so it is safe to keep.
+ const ext = file.fieldname === 'poster' ? '.jpg' : (path.extname(file.originalname).toLowerCase() || '.mp4');
+ cb(null, `${crypto.randomUUID()}${ext}`);
+ },
}),
limits: { fileSize: MAX_VIDEO_SIZE },
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
@@ -259,10 +267,18 @@ export class JourneyController {
@Body() body: { duration_ms?: string },
) {
const video = files?.video?.[0];
+ const poster = files?.poster?.[0];
+ // multer already wrote both parts; clean them up on any rejection so a POST to
+ // a journey the user can't edit doesn't orphan a 500 MB clip on disk (#823).
+ const cleanup = () => {
+ for (const f of [video, poster]) {
+ if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
+ }
+ };
if (!video) {
+ cleanup();
throw new HttpException({ error: 'No video uploaded' }, 400);
}
- const poster = files?.poster?.[0];
const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null;
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, [{
path: `journey/${video.filename}`,
@@ -271,6 +287,7 @@ export class JourneyController {
durationMs: durationMs != null && Number.isFinite(durationMs) ? durationMs : null,
}]);
if (!photos.length) {
+ cleanup();
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { photos };
diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts
index d07e5b78..a71eee85 100644
--- a/server/src/services/journeyShareService.ts
+++ b/server/src/services/journeyShareService.ts
@@ -105,7 +105,8 @@ export function getPublicJourney(token: string) {
const photos = db.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
- tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
+ tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
+ tkp.media_type, tkp.duration_ms
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
@@ -120,7 +121,8 @@ export function getPublicJourney(token: string) {
const gallery = db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
- tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
+ tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
+ tkp.media_type, tkp.duration_ms
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts
index 1311ab40..6d5c8222 100644
--- a/server/src/services/memories/photoResolverService.ts
+++ b/server/src/services/memories/photoResolverService.ts
@@ -126,6 +126,7 @@ export async function streamPhoto(
const thumbAbs = path.join(uploadsRoot, thumbRel);
if (fs.existsSync(thumbAbs)) {
res.set('Cache-Control', 'public, max-age=86400, immutable');
+ res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(thumbAbs);
return;
}
@@ -142,6 +143,7 @@ export async function streamPhoto(
const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400');
+ res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(localPath);
return;
}