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
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { requireAdmin } from "@/lib/auth";
import { publishVideoJob } from "@/lib/video-platforms";
import { revalidatePath } from "next/cache";
export async function createVideoUpload(formData: FormData) {
const discordId = await requireAdmin();
const { saveVideoUpload } = await import("@/lib/video-upload-storage");
const id = await saveVideoUpload(formData, discordId);
revalidatePath("/admin/upload");
return { id };
}
export async function retryVideoJob(jobId: string) {
await requireAdmin();
await publishVideoJob(jobId).catch(() => undefined);
revalidatePath("/admin/upload");
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { CalendarClock, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/date-time-picker";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
export function UploadForm() {
const formRef = useRef<HTMLFormElement>(null);
const uploadRequestRef = useRef<XMLHttpRequest | null>(null);
const [pending, startTransition] = useTransition();
const [scheduledAt, setScheduledAt] = useState<Date>();
const [video, setVideo] = useState<File | null>(null);
const [stagedVideoId, setStagedVideoId] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState<
"idle" | "uploading" | "completed" | "failed"
>("idle");
const [youtube, setYoutube] = useState({
privacyStatus: "public",
categoryId: "22",
tags: "",
madeForKids: false,
containsSyntheticMedia: false,
});
const [tiktok, setTiktok] = useState({
privacyLevel: "PUBLIC_TO_EVERYONE",
disableComment: false,
disableDuet: false,
disableStitch: false,
coverTimestampMs: 0,
isAigc: false,
});
function uploadVideo(file: File) {
uploadRequestRef.current?.abort();
setVideo(file);
setStagedVideoId(null);
setUploadProgress(0);
setUploadStatus("uploading");
const request = new XMLHttpRequest();
uploadRequestRef.current = request;
request.open("POST", "/api/admin/video-file");
request.setRequestHeader("Content-Type", file.type);
request.setRequestHeader("X-File-Name", encodeURIComponent(file.name));
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
setUploadProgress(Math.round((event.loaded / event.total) * 100));
}
};
request.onload = () => {
try {
const data = JSON.parse(request.responseText) as {
stagedVideoId?: string;
error?: string;
};
if (
request.status < 200 ||
request.status >= 300 ||
!data.stagedVideoId
) {
throw new Error(data.error || "Video upload failed.");
}
setStagedVideoId(data.stagedVideoId);
setUploadProgress(100);
setUploadStatus("completed");
toast.success("Video uploaded. You can finish the metadata now.");
} catch (error) {
setUploadStatus("failed");
toast.error(
error instanceof Error ? error.message : "Video upload failed.",
);
}
};
request.onerror = () => {
setUploadStatus("failed");
toast.error(
"Video upload failed because the connection was interrupted.",
);
};
request.send(file);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
form.delete("video");
form.set("stagedVideoId", stagedVideoId ?? "");
form.set("videoName", video?.name ?? "");
form.set("videoMimeType", video?.type ?? "");
form.set("scheduledAt", scheduledAt?.toISOString() ?? "");
form.set(
"youtubeSettings",
JSON.stringify({
...youtube,
tags: youtube.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
}),
);
form.set(
"tiktokSettings",
JSON.stringify({
...tiktok,
coverTimestampMs: Number(tiktok.coverTimestampMs) || 0,
}),
);
startTransition(async () => {
try {
const response = await fetch("/api/admin/video-upload", {
method: "POST",
body: form,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Upload failed.");
}
toast.success(scheduledAt ? "Upload scheduled." : "Upload started.");
formRef.current?.reset();
setVideo(null);
setStagedVideoId(null);
setUploadProgress(0);
setUploadStatus("idle");
setScheduledAt(undefined);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.");
}
});
}
return (
<form
ref={formRef}
onSubmit={submit}
className="grid items-start gap-4 md:grid-cols-2"
>
<Card>
<CardHeader>
<CardTitle>New upload</CardTitle>
<CardDescription>One source file, two platform jobs.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<FieldGroup>
<Field>
<FieldLabel htmlFor="video">Video file</FieldLabel>
<Input
id="video"
type="file"
accept="video/mp4,video/quicktime,video/webm"
required
onChange={(event) => {
const file = event.target.files?.[0];
if (file) uploadVideo(file);
}}
/>
<FieldDescription>
{video
? `${video.name} · ${(video.size / 1024 / 1024).toFixed(1)} MB`
: "MP4, MOV, or WebM. No application size limit."}
</FieldDescription>
</Field>
{uploadStatus !== "idle" && (
<Field>
<div className="flex items-center justify-between gap-3 text-sm">
<FieldLabel>Background upload</FieldLabel>
<span className="text-muted-foreground">
{uploadStatus === "failed"
? "Failed"
: `${uploadProgress}%`}
</span>
</div>
<Progress value={uploadProgress} />
<FieldDescription>
{uploadStatus === "completed"
? "Upload complete. The file is ready to publish."
: uploadStatus === "failed"
? "Choose the file again to retry."
: "You can continue filling in metadata while this runs."}
</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor="thumbnail">YouTube thumbnail</FieldLabel>
<Input
id="thumbnail"
name="thumbnail"
type="file"
accept="image/*"
/>
<FieldDescription>
TikTok uses the video frame selected below.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="title">Shared title / caption</FieldLabel>
<Input
id="title"
name="title"
placeholder="Give this video a title"
required
maxLength={2200}
/>
</Field>
<Field>
<FieldLabel htmlFor="description">YouTube description</FieldLabel>
<Textarea
id="description"
name="description"
rows={7}
placeholder="Tell viewers about this video"
/>
</Field>
<Field>
<FieldLabel htmlFor="scheduledAt">Publish date</FieldLabel>
<DateTimePicker
id="scheduledAt"
value={scheduledAt}
onChange={setScheduledAt}
placeholder="Publish immediately"
/>
<FieldDescription>
Leave empty to publish as soon as both platforms accept the
upload.
</FieldDescription>
</Field>
</FieldGroup>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>YouTube</CardTitle>
<CardDescription>
Platform-specific publishing settings.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Visibility</FieldLabel>
<Select
value={youtube.privacyStatus}
onValueChange={(value) =>
setYoutube((state) => ({
...state,
privacyStatus: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="unlisted">Unlisted</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="category">Category ID</FieldLabel>
<Input
id="category"
value={youtube.categoryId}
onChange={(event) =>
setYoutube((state) => ({
...state,
categoryId: event.target.value,
}))
}
/>
</Field>
<Field>
<FieldLabel htmlFor="tags">Tags</FieldLabel>
<Input
id="tags"
value={youtube.tags}
onChange={(event) =>
setYoutube((state) => ({
...state,
tags: event.target.value,
}))
}
placeholder="gaming, erika, shorts"
/>
</Field>
<Field orientation="horizontal">
<Checkbox
checked={youtube.madeForKids}
onCheckedChange={(checked) =>
setYoutube((state) => ({
...state,
madeForKids: checked === true,
}))
}
/>
<FieldLabel>Made for kids</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox
checked={youtube.containsSyntheticMedia}
onCheckedChange={(checked) =>
setYoutube((state) => ({
...state,
containsSyntheticMedia: checked === true,
}))
}
/>
<FieldLabel>Contains altered or synthetic media</FieldLabel>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>TikTok</CardTitle>
<CardDescription>Direct Post controls.</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Privacy</FieldLabel>
<Select
value={tiktok.privacyLevel}
onValueChange={(value) =>
setTiktok((state) => ({
...state,
privacyLevel: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="PUBLIC_TO_EVERYONE">
Everyone
</SelectItem>
<SelectItem value="FOLLOWER_OF_CREATOR">
Followers
</SelectItem>
<SelectItem value="MUTUAL_FOLLOW_FRIENDS">
Friends
</SelectItem>
<SelectItem value="SELF_ONLY">Only me</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="cover">
Cover frame (milliseconds)
</FieldLabel>
<Input
id="cover"
type="number"
min="0"
value={tiktok.coverTimestampMs}
onChange={(event) =>
setTiktok((state) => ({
...state,
coverTimestampMs: Number(event.target.value),
}))
}
/>
</Field>
<FieldSet>
<FieldLegend variant="label">Engagement</FieldLegend>
<Field orientation="horizontal">
<Switch
checked={!tiktok.disableComment}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
disableComment: !checked,
}))
}
/>
<FieldLabel>Allow comments</FieldLabel>
</Field>
<Field orientation="horizontal">
<Switch
checked={!tiktok.disableDuet}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
disableDuet: !checked,
}))
}
/>
<FieldLabel>Allow Duet</FieldLabel>
</Field>
<Field orientation="horizontal">
<Switch
checked={!tiktok.disableStitch}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
disableStitch: !checked,
}))
}
/>
<FieldLabel>Allow Stitch</FieldLabel>
</Field>
</FieldSet>
<Field orientation="horizontal">
<Checkbox
checked={tiktok.isAigc}
onCheckedChange={(checked) =>
setTiktok((state) => ({
...state,
isAigc: checked === true,
}))
}
/>
<FieldLabel>AI-generated content</FieldLabel>
</Field>
</FieldGroup>
</CardContent>
</Card>
<Button
type="submit"
size="lg"
disabled={pending || uploadStatus !== "completed"}
>
{pending ? (
<CalendarClock data-icon="inline-start" />
) : (
<Upload data-icon="inline-start" />
)}
{pending
? "Sending…"
: scheduledAt
? "Schedule both uploads"
: "Upload to both platforms"}
</Button>
</div>
</form>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { AdminShellServer } from "@/components/admin-shell-server";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getLatestTiktokVideos,
getLatestYoutubeVideos,
} from "@/lib/video-platforms";
import { db } from "@/db";
import { videoUploads } from "@/db/schema";
import { desc } from "drizzle-orm";
import {
Clapperboard,
ExternalLink,
History,
PlaySquare,
UploadCloud,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { UploadForm } from "./client";
import { retryVideoJob } from "./actions";
export const dynamic = "force-dynamic";
function statusVariant(status: string) {
return status === "completed"
? "default"
: status === "failed"
? "destructive"
: "secondary";
}
export default async function AdminUploadPage() {
const [youtube, tiktok, uploads] = await Promise.all([
getLatestYoutubeVideos().catch(() => []),
getLatestTiktokVideos().catch(() => []),
db.query.videoUploads.findMany({
orderBy: [desc(videoUploads.createdAt)],
limit: 20,
}),
]);
const allJobs = uploads.length
? await db.query.videoUploadJobs.findMany({
where: (table, { inArray }) =>
inArray(
table.uploadId,
uploads.map((upload) => upload.id),
),
})
: [];
const jobsByUpload = new Map<string, typeof allJobs>();
for (const job of allJobs)
jobsByUpload.set(job.uploadId, [
...(jobsByUpload.get(job.uploadId) ?? []),
job,
]);
return (
<AdminShellServer>
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 pb-12 md:p-6">
<div>
<p className="text-sm font-medium text-primary">Creator studio</p>
<h1 className="text-3xl font-bold tracking-tight">
Upload everywhere
</h1>
<p className="mt-1 text-muted-foreground">
Prepare one video, then publish it to YouTube and TikTok together.
</p>
</div>
<Tabs defaultValue="upload" className="w-full">
<TabsList className="grid w-full grid-cols-3 md:w-fit">
<TabsTrigger value="upload">
<UploadCloud data-icon="inline-start" />
Upload
</TabsTrigger>
<TabsTrigger value="recent">
<Clapperboard data-icon="inline-start" />
Recent videos
</TabsTrigger>
<TabsTrigger value="history">
<History data-icon="inline-start" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="upload">
<UploadForm />
</TabsContent>
<TabsContent value="recent">
<section className="grid gap-4 xl:grid-cols-2">
<LatestCard title="Latest YouTube videos" videos={youtube} />
<LatestCard title="Latest TikTok videos" videos={tiktok} />
</section>
</TabsContent>
<TabsContent value="history">
<Card>
<CardHeader>
<CardTitle>Upload history</CardTitle>
<CardDescription>
Scheduled and recent publishing jobs.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{uploads.length === 0 ? (
<p className="text-sm text-muted-foreground">
No uploads yet.
</p>
) : (
uploads.map((upload) => (
<div
key={upload.id}
className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0">
<p className="truncate font-medium">{upload.title}</p>
<p className="text-sm text-muted-foreground">
{upload.scheduledAt
? `Scheduled ${upload.scheduledAt.toLocaleString("en-GB", { timeZone: "Asia/Bangkok" })}`
: upload.createdAt.toLocaleString("en-GB", {
timeZone: "Asia/Bangkok",
})}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(upload.status)}>
{upload.status}
</Badge>
{(jobsByUpload.get(upload.id) ?? []).map((job) => (
<span
key={job.id}
className="flex items-center gap-1"
>
<Badge variant={statusVariant(job.status)}>
{job.platform}: {job.status}
</Badge>
{job.status === "failed" && (
<form action={retryVideoJob.bind(null, job.id)}>
<Button
type="submit"
size="sm"
variant="outline"
>
Retry
</Button>
</form>
)}
</span>
))}
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</AdminShellServer>
);
}
function LatestCard({
title,
videos,
}: {
title: string;
videos: Array<{
id: string;
title: string;
thumbnailUrl: string;
url: string;
platform: "youtube" | "tiktok";
}>;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>Most recent 10 published videos.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
{videos.length === 0 ? (
<p className="text-sm text-muted-foreground">
Connect the platform API to load videos.
</p>
) : (
videos.map((video) => (
<Link
key={video.id}
href={video.url}
target="_blank"
className="group flex gap-3 rounded-lg border p-2 transition-colors hover:border-primary/60"
>
<div className="relative aspect-square w-28 shrink-0 overflow-hidden rounded-md bg-muted">
{video.thumbnailUrl ? (
<Image
src={video.thumbnailUrl}
alt=""
fill
sizes="112px"
unoptimized={video.platform === "tiktok"}
referrerPolicy="no-referrer"
className="object-cover"
/>
) : (
<PlaySquare className="absolute inset-0 m-auto text-muted-foreground" />
)}
</div>
<span className="min-w-0 text-sm font-medium leading-snug group-hover:underline">
{video.title}
</span>
<ExternalLink className="mt-0.5 shrink-0 text-muted-foreground" />
</Link>
))
)}
</CardContent>
</Card>
);
}
+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("&", "&amp;")
.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 });
}