375 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
);
|
|
}
|