import { FastifyInstance } from 'fastify'; import { createReadStream, stat } from 'fs'; import { access } from 'fs/promises'; import { join } from 'path'; import { lookup } from 'mime-types'; import { prisma } from '../../../config/database'; import { optionalAuth } from '../middleware/auth'; import { logger } from '../../../utils/logger'; /** * Public routes for media gallery * No authentication required, only shows published videos */ interface PublicVideosQuery { limit?: string; offset?: string; search?: string; sort?: 'recent' | 'popular' | 'oldest'; category?: 'videos' | 'curated' | 'compilations' | 'playback' | 'highlights'; } export async function publicRoutes(fastify: FastifyInstance) { // GET /api/public - List published videos (unauthenticated) fastify.get<{ Querystring: PublicVideosQuery }>( '/public', { preHandler: optionalAuth, }, async (request, reply) => { const limit = parseInt(request.query.limit || '24'); const offset = parseInt(request.query.offset || '0'); const search = request.query.search; const sort = request.query.sort || 'recent'; const category = request.query.category; // Build Prisma WHERE clause - only published videos const where: any = { isPublished: true, isLocked: false, // Don't show locked videos }; if (search) { where.title = { contains: search, mode: 'insensitive', }; } if (category) { where.category = category; } // Build orderBy clause let orderBy: any = { publishedAt: 'desc' }; // Default: recent if (sort === 'oldest') { orderBy = { publishedAt: 'asc' }; } else if (sort === 'popular') { // TODO: Sort by view count when analytics are implemented orderBy = { publishedAt: 'desc' }; } const videos = await prisma.video.findMany({ where, select: { id: true, title: true, filename: true, durationSeconds: true, fileSize: true, width: true, height: true, orientation: true, quality: true, producer: true, thumbnailPath: true, publishedAt: true, category: true, isLocked: true, accessLevel: true, viewCount: true, upvoteCount: true, commentCount: true, createdAt: true, }, orderBy, take: limit, skip: offset, }); // Get total count const total = await prisma.video.count({ where }); // Map videos to include URLs const videosWithUrls = videos.map((video) => ({ ...video, duration: video.durationSeconds, thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null, videoUrl: `/media/videos/${video.id}/stream`, })); return { videos: videosWithUrls, pagination: { total, limit, offset, hasMore: offset + limit < total, }, }; } ); // GET /api/public/:id - Get single published video (unauthenticated) fastify.get<{ Params: { id: string } }>( '/public/:id', { preHandler: optionalAuth, }, async (request, reply) => { const videoId = parseInt(request.params.id); const video = await prisma.video.findFirst({ where: { id: videoId, isPublished: true, isLocked: false, }, }); if (!video) { return reply.code(404).send({ message: 'Video not found or not published' }); } return { video: { ...video, duration: video.durationSeconds, thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null, videoUrl: `/media/videos/${video.id}/stream`, }, }; } ); // GET /api/public/categories - Get list of available categories with counts fastify.get('/public/categories', async (request, reply) => { const categories = await prisma.video.groupBy({ by: ['category'], where: { isPublished: true, isLocked: false, category: { not: null }, }, _count: { category: true, }, }); return categories.map((cat) => ({ name: cat.category, count: cat._count.category, })); }); // GET /api/public/producers - Get list of producers (public) fastify.get('/public/producers', async (request, reply) => { const videos = await prisma.video.findMany({ where: { isPublished: true, isLocked: false, producer: { not: null }, }, select: { producer: true, }, distinct: ['producer'], }); return videos.map((v) => v.producer).filter(Boolean); }); // GET /api/public/:id/thumbnail - Get video thumbnail (unauthenticated) fastify.get<{ Params: { id: string } }>( '/public/:id/thumbnail', { preHandler: optionalAuth, }, async (request, reply) => { const videoId = parseInt(request.params.id); const video = await prisma.video.findFirst({ where: { id: videoId, isPublished: true, isLocked: false, }, select: { thumbnailPath: true, }, }); if (!video || !video.thumbnailPath) { return reply.code(404).send({ message: 'Thumbnail not found' }); } // Validate path doesn't contain traversal attempts if (video.thumbnailPath.includes('..')) { logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`); return reply.code(403).send({ message: 'Access denied' }); } const thumbnailPath = video.thumbnailPath; // Check file exists try { await access(thumbnailPath); } catch { return reply.code(404).send({ message: 'Thumbnail file not found' }); } // Get file stats const stats = await new Promise<{ size: number } | null>((resolve) => { stat(thumbnailPath, (err, stats) => { if (err) resolve(null); else resolve({ size: stats.size }); }); }); if (!stats) { return reply.code(404).send({ message: 'Thumbnail file not found' }); } // Determine MIME type const mimeType = lookup(thumbnailPath) || 'image/jpeg'; // Send file reply.header('Content-Type', mimeType); reply.header('Content-Length', stats.size); reply.header('Accept-Ranges', 'bytes'); const stream = createReadStream(thumbnailPath); return reply.send(stream); } ); // GET /api/public/:id/stream - Stream video file (unauthenticated) fastify.get<{ Params: { id: string } }>( '/public/:id/stream', { preHandler: optionalAuth, }, async (request, reply) => { const videoId = parseInt(request.params.id); const video = await prisma.video.findFirst({ where: { id: videoId, isPublished: true, isLocked: false, }, select: { path: true, filename: true, accessLevel: true, }, }); if (!video) { return reply.code(404).send({ message: 'Video not found or not published' }); } // Content gating: check access level against user subscription if (video.accessLevel && video.accessLevel !== 'free') { const userId = (request as any).user?.id; if (!userId) { return reply.code(403).send({ message: 'This content requires a subscription', accessLevel: video.accessLevel, requiresAuth: true, }); } const subscription = await prisma.userSubscription.findFirst({ where: { userId, status: 'active', }, include: { plan: true }, }); if (!subscription) { return reply.code(403).send({ message: 'This content requires an active subscription', accessLevel: video.accessLevel, requiresSubscription: true, }); } // Premium content requires tier >= 2 if (video.accessLevel === 'premium' && (subscription.plan?.tier ?? 0) < 2) { return reply.code(403).send({ message: 'This content requires a premium subscription', accessLevel: video.accessLevel, requiresUpgrade: true, }); } } // Validate path doesn't contain traversal attempts if (video.path.includes('..') || video.filename.includes('..')) { logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`); return reply.code(403).send({ message: 'Access denied' }); } // Construct full file path const filePath = video.path.endsWith(video.filename) ? video.path : join(video.path, video.filename); // Check file exists try { await access(filePath); } catch { return reply.code(404).send({ message: 'Video file not found' }); } // Get file stats const stats = await new Promise<{ size: number } | null>((resolve) => { stat(filePath, (err, stats) => { if (err) resolve(null); else resolve({ size: stats.size }); }); }); if (!stats) { return reply.code(404).send({ message: 'Video file not found' }); } const fileSize = stats.size; const mimeType = lookup(filePath) || 'video/mp4'; const range = request.headers.range; // Handle range request (for video seeking) if (range) { const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) { reply.header('Content-Range', `bytes */${fileSize}`); return reply.code(416).send({ message: 'Requested range not satisfiable' }); } const chunkSize = (end - start) + 1; reply.code(206); reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`); reply.header('Accept-Ranges', 'bytes'); reply.header('Content-Length', chunkSize); reply.header('Content-Type', mimeType); const stream = createReadStream(filePath, { start, end }); return reply.send(stream); } else { // No range - send full file reply.header('Content-Length', fileSize); reply.header('Content-Type', mimeType); reply.header('Accept-Ranges', 'bytes'); const stream = createReadStream(filePath); return reply.send(stream); } } ); }