2026-02-18 10:01:54 -07:00

375 lines
11 KiB
TypeScript

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);
}
}
);
}