160 lines
4.7 KiB
TypeScript
160 lines
4.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { unlink, writeFile } from "node:fs/promises";
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import { getAuthenticatedUser } from "@/lib/auth";
|
|
import { db } from "@/lib/db";
|
|
import { getChannel, isSetupComplete } from "@/lib/setup";
|
|
import {
|
|
ensureStorageDirectories,
|
|
getThumbnailExtension,
|
|
getThumbnailPath,
|
|
getVideoPath,
|
|
} from "@/lib/storage";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const metadataSchema = z.object({
|
|
title: z.string().trim().min(1).max(140),
|
|
description: z.string().trim().max(5000).optional(),
|
|
});
|
|
|
|
const maxVideoBytes = Number(process.env.MAX_VIDEO_UPLOAD_BYTES ?? 1_073_741_824);
|
|
const maxThumbnailBytes = Number(process.env.MAX_THUMBNAIL_UPLOAD_BYTES ?? 5_242_880);
|
|
|
|
function redirectWithError(request: Request, error: string) {
|
|
const url = new URL("/dashboard", request.url);
|
|
url.searchParams.set("error", error);
|
|
return NextResponse.redirect(url, 303);
|
|
}
|
|
|
|
function redirectWithSuccess(request: Request, success: string) {
|
|
const url = new URL("/dashboard", request.url);
|
|
url.searchParams.set("success", success);
|
|
return NextResponse.redirect(url, 303);
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
if (!(await isSetupComplete())) {
|
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
|
}
|
|
|
|
const user = await getAuthenticatedUser();
|
|
if (!user) {
|
|
return NextResponse.redirect(new URL("/login", request.url), 303);
|
|
}
|
|
|
|
const channel = await getChannel();
|
|
if (!channel) {
|
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const metadata = metadataSchema.safeParse({
|
|
title: formData.get("title"),
|
|
description: formData.get("description") || undefined,
|
|
});
|
|
|
|
if (!metadata.success) {
|
|
return redirectWithError(request, "Video title is required.");
|
|
}
|
|
|
|
const videoInput = formData.get("video");
|
|
if (!(videoInput instanceof File)) {
|
|
return redirectWithError(request, "Please select a video file.");
|
|
}
|
|
|
|
if (videoInput.size <= 0) {
|
|
return redirectWithError(request, "Video file cannot be empty.");
|
|
}
|
|
|
|
if (videoInput.size > maxVideoBytes) {
|
|
return redirectWithError(request, "Video exceeds the configured upload size limit.");
|
|
}
|
|
|
|
const lowerVideoName = videoInput.name.toLowerCase();
|
|
const isMp4 = videoInput.type === "video/mp4" || lowerVideoName.endsWith(".mp4");
|
|
if (!isMp4) {
|
|
return redirectWithError(request, "Only MP4 uploads are supported right now.");
|
|
}
|
|
|
|
const thumbnailInput = formData.get("thumbnail");
|
|
const thumbnailFile =
|
|
thumbnailInput instanceof File && thumbnailInput.size > 0
|
|
? thumbnailInput
|
|
: null;
|
|
|
|
let thumbnailExtension: string | null = null;
|
|
if (thumbnailFile) {
|
|
if (thumbnailFile.size > maxThumbnailBytes) {
|
|
return redirectWithError(
|
|
request,
|
|
"Thumbnail exceeds the configured upload size limit.",
|
|
);
|
|
}
|
|
|
|
thumbnailExtension = getThumbnailExtension(thumbnailFile);
|
|
if (!thumbnailExtension) {
|
|
return redirectWithError(
|
|
request,
|
|
"Thumbnail must be JPG, PNG, or WEBP.",
|
|
);
|
|
}
|
|
}
|
|
|
|
const videoId = randomUUID().replaceAll("-", "");
|
|
const videoFilename = `${videoId}.mp4`;
|
|
const videoPath = getVideoPath(videoFilename);
|
|
|
|
let thumbnailFilename: string | null = null;
|
|
let thumbnailMimeType: string | null = null;
|
|
let thumbnailPath: string | null = null;
|
|
|
|
if (thumbnailFile && thumbnailExtension) {
|
|
thumbnailFilename = `${videoId}${thumbnailExtension}`;
|
|
thumbnailMimeType =
|
|
thumbnailFile.type ||
|
|
(thumbnailExtension === ".png"
|
|
? "image/png"
|
|
: thumbnailExtension === ".webp"
|
|
? "image/webp"
|
|
: "image/jpeg");
|
|
thumbnailPath = getThumbnailPath(thumbnailFilename);
|
|
}
|
|
|
|
try {
|
|
await ensureStorageDirectories();
|
|
|
|
await writeFile(videoPath, Buffer.from(await videoInput.arrayBuffer()));
|
|
|
|
if (thumbnailFile && thumbnailPath) {
|
|
await writeFile(thumbnailPath, Buffer.from(await thumbnailFile.arrayBuffer()));
|
|
}
|
|
|
|
await db.video.create({
|
|
data: {
|
|
id: videoId,
|
|
channelId: channel.id,
|
|
title: metadata.data.title,
|
|
description: metadata.data.description || null,
|
|
videoFilename,
|
|
videoMimeType: videoInput.type || "video/mp4",
|
|
videoSizeBytes: videoInput.size,
|
|
thumbnailFilename,
|
|
thumbnailMimeType,
|
|
},
|
|
});
|
|
|
|
return redirectWithSuccess(request, "Upload complete.");
|
|
} catch {
|
|
await Promise.all([
|
|
unlink(videoPath).catch(() => undefined),
|
|
thumbnailPath ? unlink(thumbnailPath).catch(() => undefined) : undefined,
|
|
]);
|
|
|
|
return redirectWithError(request, "Upload failed. Please try again.");
|
|
}
|
|
}
|