feat: implement admin video upload system with platform integration for YouTube and TikTok

This commit is contained in:
2026-08-02 02:07:35 +07:00 Unverified
parent 7cec08fb77
commit 698b12039a
22 changed files with 1727 additions and 14 deletions
+64
View File
@@ -0,0 +1,64 @@
import { createWriteStream } from "node:fs";
import { mkdir, unlink } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
export const dynamic = "force-dynamic";
const VIDEO_DIR = process.env.VIDEO_UPLOAD_DIR || "/nfs/erika";
const VIDEO_TYPES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
function safeFileName(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-180) || "video";
}
export async function POST(request: Request) {
let filePath: string | null = null;
try {
const discordId = await requireAdmin();
const encodedName = request.headers.get("x-file-name");
const mimeType = request.headers.get("content-type") ?? "";
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (!encodedName || !request.body) {
return NextResponse.json(
{ error: "No video provided." },
{ status: 400 },
);
}
if (!VIDEO_TYPES.has(mimeType)) {
return NextResponse.json(
{ error: "Use an MP4, MOV, or WebM video." },
{ status: 400 },
);
}
const originalFilename = decodeURIComponent(encodedName);
const stagedVideoId = randomUUID();
const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`;
filePath = `${stagingDirectory}/${stagedVideoId}-${safeFileName(originalFilename)}`;
await mkdir(stagingDirectory, { recursive: true });
await pipeline(
Readable.fromWeb(request.body as never),
createWriteStream(filePath, { flags: "wx" }),
);
return NextResponse.json({
stagedVideoId,
originalFilename,
mimeType,
size: contentLength,
});
} catch (error) {
if (filePath) await unlink(filePath).catch(() => undefined);
const message = error instanceof Error ? error.message : "Upload failed.";
const status =
message === "Unauthorized" || message === "Not authenticated" ? 401 : 400;
return NextResponse.json({ error: message }, { status });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
import { saveVideoUpload } from "@/lib/video-upload-storage";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const discordId = await requireAdmin();
const id = await saveVideoUpload(await request.formData(), discordId);
return NextResponse.json({ id });
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed.";
const status =
message === "Unauthorized" || message === "Not authenticated" ? 401 : 400;
return NextResponse.json({ error: message }, { status });
}
}
+1
View File
@@ -20,6 +20,7 @@ function escapeHtml(value: unknown) {
* Displays the tokens so you can copy them to your .env file.
*/
export async function GET(request: NextRequest) {
await requireAdmin();
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
+2 -2
View File
@@ -11,7 +11,7 @@ const TIKTOK_OAUTH_STATE_COOKIE = "tiktok-oauth-state";
* Visit this URL once to start the OAuth flow and get your refresh token.
*/
export async function GET() {
await requireAdmin();
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const redirectUri = `${process.env.BASE_URL}/api/auth/tiktok/callback`;
@@ -27,7 +27,7 @@ export async function GET() {
const params = new URLSearchParams({
client_key: clientKey,
scope: "user.info.basic,user.info.profile,user.info.stats,video.list",
scope: "user.info.basic,user.info.profile,user.info.stats,video.list,video.publish",
response_type: "code",
redirect_uri: redirectUri,
state: csrfState,
+50
View File
@@ -0,0 +1,50 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
function escapeHtml(value: unknown) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export async function GET(request: NextRequest) {
await requireAdmin();
const params = new URL(request.url).searchParams;
const cookieStore = await cookies();
const expected = cookieStore.get("youtube-oauth-state")?.value;
cookieStore.delete("youtube-oauth-state");
if (!expected || expected !== params.get("state"))
return NextResponse.json(
{ error: "Invalid OAuth state." },
{ status: 400 },
);
const code = params.get("code");
if (!code)
return NextResponse.json(
{ error: "No authorization code received." },
{ status: 400 },
);
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: process.env.YOUTUBE_CLIENT_ID ?? "",
client_secret: process.env.YOUTUBE_CLIENT_SECRET ?? "",
redirect_uri: `${process.env.BASE_URL}/api/auth/youtube/callback`,
grant_type: "authorization_code",
}),
});
const data = (await response.json()) as Record<string, unknown>;
if (!response.ok || !data.refresh_token)
return NextResponse.json(
{ error: "YouTube token exchange failed." },
{ status: 400 },
);
const html = `<!doctype html><html><body style="font-family:system-ui;max-width:720px;margin:40px auto;padding:20px"><h1>YouTube OAuth success</h1><p>Copy these values into your server environment:</p><pre>YOUTUBE_REFRESH_TOKEN=${escapeHtml(data.refresh_token)}\nYOUTUBE_ACCESS_TOKEN=${escapeHtml(data.access_token)}</pre><p>Restart the app after saving them.</p></body></html>`;
return new NextResponse(html, { headers: { "Content-Type": "text/html" } });
}
+37
View File
@@ -0,0 +1,37 @@
import { randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
const COOKIE = "youtube-oauth-state";
export async function GET() {
await requireAdmin();
const clientId = process.env.YOUTUBE_CLIENT_ID;
const redirectUri = `${process.env.BASE_URL}/api/auth/youtube/callback`;
if (!clientId)
return NextResponse.json(
{ error: "YOUTUBE_CLIENT_ID is not configured." },
{ status: 500 },
);
const state = randomBytes(32).toString("hex");
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.search = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
access_type: "offline",
prompt: "consent",
scope: "https://www.googleapis.com/auth/youtube.upload",
state,
}).toString();
const response = NextResponse.redirect(url);
(await cookies()).set(COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 600,
path: "/api/auth/youtube",
});
return response;
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { processDueVideoUploads } from "@/lib/video-platforms";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authorization = request.headers.get("authorization");
if (
!process.env.CRON_SECRET ||
authorization !== `Bearer ${process.env.CRON_SECRET}`
)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await processDueVideoUploads();
return NextResponse.json({ ok: true });
}