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.caption + {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.caption + )}
{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; }