diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 279b581f..8f40bbad 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -56,6 +56,9 @@ export interface JourneyPhoto { thumbnail_path?: string | null width?: number | null height?: number | null + // 'image' (default) or 'video' (#823) + media_type?: string | null + duration_ms?: number | null } export interface GalleryPhoto { @@ -74,6 +77,9 @@ export interface GalleryPhoto { thumbnail_path?: string | null width?: number | null height?: number | null + // 'image' (default) or 'video' (#823) + media_type?: string | null + duration_ms?: number | null } export interface JourneyTrip { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index c1be0b98..f99e5686 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3107,6 +3107,22 @@ function runMigrations(db: Database.Database): void { } db.exec('UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL'); }, + // Video support (#823): the trek_photos registry held only images. media_type + // discriminates image vs video so the gallery, lightbox and provider proxy can + // branch; duration_ms is optional metadata for the player. Additive — existing + // rows default to 'image'. + () => { + for (const stmt of [ + "ALTER TABLE trek_photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image'", + 'ALTER TABLE trek_photos ADD COLUMN duration_ms INTEGER', + ]) { + try { + db.exec(stmt); + } catch (err: any) { + if (!err.message?.includes('duplicate column name')) throw err; + } + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 2a092f73..3291b85e 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -15,7 +15,7 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; -import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; +import { FileInterceptor, FilesInterceptor, FileFieldsInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import path from 'node:path'; import fs from 'node:fs'; @@ -25,7 +25,7 @@ import { JourneyService } from './journey.service'; import { JourneyAddonGuard } from './journey-addon.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { getAllowedExtensions } from '../../services/fileService'; +import { getAllowedExtensions, isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../../services/fileService'; const uploadsBase = path.join(__dirname, '../../../uploads/journey'); const IMAGE_UPLOAD = { @@ -51,6 +51,33 @@ const IMAGE_UPLOAD = { }, }; +// Gallery video upload (#823): one video plus an optional client-captured poster +// image, written to the same uploads/journey store. Larger cap than images since +// phone clips are big; videos are stored as-is and streamed with HTTP Range. +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')}`), + }), + limits: { fileSize: MAX_VIDEO_SIZE }, + fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { + const reject = (msg: string) => { + const err: Error & { statusCode?: number } = new Error(msg); + err.statusCode = 400; + cb(err, false); + }; + if (file.fieldname === 'poster') { + if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) return reject('Poster must be an image'); + return cb(null, true); + } + // 'video' field + const ext = path.extname(file.originalname).toLowerCase().replace('.', ''); + if (!isVideoMime(file.mimetype)) return reject('Only video files are allowed'); + if (!isVideoExtension(ext)) return reject(`Video type .${ext} is not allowed`); + cb(null, true); + }, +}; + /** * /api/journeys — cross-trip travel narrative (journeys, entries, photo gallery * + provider mirroring, contributors, preferences, share links). @@ -222,6 +249,32 @@ export class JourneyController { return { photos }; } + @Post(':id/gallery/video') + @UseInterceptors(FileFieldsInterceptor([{ name: 'video', maxCount: 1 }, { name: 'poster', maxCount: 1 }], VIDEO_UPLOAD)) + uploadGalleryVideo( + @CurrentUser() user: User, + @Param('id') id: string, + @UploadedFiles() files: { video?: Express.Multer.File[]; poster?: Express.Multer.File[] } | undefined, + @Body() body: { duration_ms?: string }, + ) { + const video = files?.video?.[0]; + if (!video) { + 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}`, + thumbnail: poster ? `journey/${poster.filename}` : undefined, + mediaType: 'video', + durationMs: durationMs != null && Number.isFinite(durationMs) ? durationMs : null, + }]); + if (!photos.length) { + throw new HttpException({ error: 'Not allowed' }, 403); + } + return { photos }; + } + @Post(':id/gallery/provider-photos') galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; passphrase?: string }) { const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; diff --git a/server/src/services/fileService.ts b/server/src/services/fileService.ts index f596c4b8..87cfcf6c 100644 --- a/server/src/services/fileService.ts +++ b/server/src/services/fileService.ts @@ -12,6 +12,21 @@ import { TripFile } from '../types'; export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass'; + +// Video support (#823). Gallery/media uploads accept these in addition to images, +// independent of the admin doc-types allowlist. Videos are stored as-is and +// streamed with HTTP Range; the cap is higher than images because phone clips are +// large. +export const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov']; +export const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB + +export function isVideoMime(mime: string | undefined | null): boolean { + return !!mime && mime.startsWith('video/'); +} + +export function isVideoExtension(ext: string): boolean { + return VIDEO_EXTENSIONS.includes(ext.toLowerCase().replace(/^\./, '')); +} // Single authoritative blocklist for every file-upload surface (main // file manager + collab attachments). When the admin setting // `allowed_file_types` is `*`, this list is still enforced so the diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index 3bc9b73c..2ddbc01f 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -16,7 +16,8 @@ function ts(): number { // id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete. const JP_SELECT = ` gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at, - tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height + tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, + tp.media_type, tp.duration_ms `; const JP_JOIN = `journey_entry_photos jep JOIN journey_photos gp ON gp.id = jep.journey_photo_id @@ -25,7 +26,8 @@ const JP_JOIN = `journey_entry_photos jep // Per-journey gallery view: journey_photos → trek_photos (no entry context). const GALLERY_SELECT = ` gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at, - tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height + tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, + tp.media_type, tp.duration_ms `; const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id'; @@ -918,7 +920,7 @@ export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId export function uploadGalleryPhotos( journeyId: number, userId: number, - filePaths: { path: string; thumbnail?: string }[], + filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[], ): JourneyPhoto[] { if (!canEdit(journeyId, userId)) return []; const results: any[] = []; @@ -929,7 +931,7 @@ export function uploadGalleryPhotos( let nextOrder = (maxOrderRow?.m ?? -1) + 1; for (const f of filePaths) { - const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail); + const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null); db.prepare( ` INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts index 3d272ca2..5aaf1741 100644 --- a/server/src/services/memories/photoResolverService.ts +++ b/server/src/services/memories/photoResolverService.ts @@ -18,6 +18,7 @@ export function getOrCreateTrekPhoto( assetId: string, ownerId: number, passphrase?: string, + mediaType: string = 'image', ): number { const existing = db.prepare( 'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?' @@ -31,8 +32,8 @@ export function getOrCreateTrekPhoto( } const res = db.prepare( - 'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)' - ).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null); + 'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type) VALUES (?, ?, ?, ?, ?)' + ).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null, mediaType); return Number(res.lastInsertRowid); } @@ -41,6 +42,8 @@ export function getOrCreateLocalTrekPhoto( thumbnailPath?: string | null, width?: number | null, height?: number | null, + mediaType: string = 'image', + durationMs?: number | null, ): number { const existing = db.prepare( "SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?" @@ -48,8 +51,8 @@ export function getOrCreateLocalTrekPhoto( if (existing) return existing.id; const res = db.prepare( - 'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)' - ).run('local', filePath, thumbnailPath || null, width || null, height || null); + 'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run('local', filePath, thumbnailPath || null, width || null, height || null, mediaType, durationMs ?? null); return Number(res.lastInsertRowid); } @@ -105,8 +108,11 @@ export async function streamPhoto( const uploadsRoot = path.join(__dirname, '../../../uploads'); if (kind === 'thumbnail') { + const isVideo = photo.media_type === 'video'; let thumbRel = photo.thumbnail_path ?? null; - if (!thumbRel) { + // Only raster images get a lazily-generated Jimp thumbnail; Jimp can't decode + // video, so a video relies on the poster captured at upload (#823). + if (!thumbRel && !isVideo) { const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path); if (result) { thumbRel = result.thumbnailRelPath; @@ -123,7 +129,13 @@ export async function streamPhoto( return; } } - // Fall through to original if thumbnail unavailable. + // A poster-less video must NOT fall through to streaming the whole file as a + // "thumbnail"; let the client render its own placeholder instead. + if (isVideo) { + res.status(404).json({ error: 'No poster available' }); + return; + } + // Images fall through to original if the thumbnail is unavailable. } const localPath = path.join(uploadsRoot, photo.file_path); diff --git a/server/src/types.ts b/server/src/types.ts index ec660e39..9b750e31 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -387,6 +387,10 @@ export interface TrekPhoto { width?: number | null; height?: number | null; passphrase?: string | null; + /** 'image' (default) or 'video' — discriminates how the asset is served/played (#823). */ + media_type?: string | null; + /** Optional video duration in milliseconds. */ + duration_ms?: number | null; created_at: string; }