Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ AUTH_DISCORD_SECRET=your_discord_client_secret
# NextAuth
AUTH_SECRET=generate_with_openssl_rand_-base64_32
AUTH_URL=http://localhost:3000
# Required when deploying behind a reverse proxy / on non-localhost hosts.
# Leave unset for `localhost` development.
# AUTH_TRUST_HOST=true

# Discord Webhook (optional) — untuk notifikasi login user ke channel Discord
DISCORD_LOG_WEBHOOK_URL=
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ node_modules
!.yarn/releases
!.yarn/versions
/prisma/db/custom.db
# Lockfiles: this project uses bun, so npm/yarn/pnpm lockfiles are
# generated artifacts (e.g. by `npm install` for one-off verification)
# and should not be committed. bun.lockb* is referenced in the Dockerfile.
/package-lock.json
/yarn.lock
/pnpm-lock.yaml

# testing
/coverage
Expand Down
4 changes: 3 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
"@typescript-eslint/no-unused-disable-directive": "off",

// React rules
"react-hooks/exhaustive-deps": "off",
// exhaustive-deps stays *enabled as a warning* — disabling it was the
// root cause of the polling-interval leak in roblox-panel.tsx.
"react-hooks/exhaustive-deps": "warn",
"react-hooks/purity": "off",
"react/no-unescaped-entities": "off",
"react/display-name": "off",
Expand Down
16 changes: 15 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "standalone",
// `audio-processor.ts` and `discord-bot.ts` use `node:fs`, `child_process`,
// and a dynamic `process.cwd()` for runtime file management. The file
// tracer follows those imports and can pull in the whole project; we
// explicitly exclude the runtime temp directories and the bot config
// file (which lives at the project root) from the trace.
outputFileTracingExcludes: {
"*": ["./.tmp-audio/**", "./bot-config.json"],
},
// NOTE: `ignoreBuildErrors` is intentionally enabled so the project builds
// in CI even when transient type issues are present (the codebase has a
// handful of pre-existing, non-fatal typing issues we're working through).
typescript: {
ignoreBuildErrors: true,
},
reactStrictMode: false,
// Strict mode in dev is invaluable for catching effect-related bugs like
// the polling-interval leak that was fixed in roblox-panel.tsx. In prod
// it's a no-op (no double-invocation).
reactStrictMode: true,
};

export default nextConfig;
6 changes: 4 additions & 2 deletions src/app/api/audio/file/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export async function GET(req: NextRequest) {
const start = m[1] ? parseInt(m[1]) : 0;
const end = m[2] ? parseInt(m[2]) : stat.size - 1;
const chunk = buf.subarray(start, end + 1);
return new NextResponse(chunk, {
// Wrap in Uint8Array — NextResponse's BodyInit typing in Next 16 doesn't
// accept Node Buffer directly, but the underlying runtime handles it.
return new NextResponse(new Uint8Array(chunk), {
status: 206,
headers: {
"Content-Type": "audio/mpeg",
Expand All @@ -39,7 +41,7 @@ export async function GET(req: NextRequest) {
}
}

return new NextResponse(buf, {
return new NextResponse(new Uint8Array(buf), {
headers: {
"Content-Type": "audio/mpeg",
"Content-Length": String(stat.size),
Expand Down
79 changes: 58 additions & 21 deletions src/app/api/roblox/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,72 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 30;

/** GET /api/roblox/status?apiKey=xxx&operationId=yyy
* → polls operation completion, returns { phase, operationDone, assetId }
* GET /api/roblox/status?apiKey=xxx&assetId=yyy
* → checks moderation status, returns { phase, moderationState, moderationReason }
/**
* Poll Roblox Open Cloud for either operation completion or asset moderation.
*
* Accepts both GET (for convenience) and POST. The API key is sent in the body
* for POST and in the `X-Roblox-Api-Key` header for GET — never in the query
* string, so it doesn't end up in reverse-proxy access logs, browser history,
* or referer headers.
*
* Body: { apiKey, operationId? , assetId? }
* GET headers: X-Roblox-Api-Key: <key>
* GET query: ?operationId=...&assetId=...
*/
export async function POST(req: NextRequest) {
try {
let apiKey: string | null = null;
let operationId: string | null = null;
let assetId: string | null = null;

const contentType = req.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const body = await req.json().catch(() => ({} as Record<string, unknown>));
apiKey = typeof body.apiKey === "string" ? body.apiKey : null;
operationId = typeof body.operationId === "string" ? body.operationId : null;
assetId = typeof body.assetId === "string" ? body.assetId : null;
} else {
// Fallback for form submissions: read from header
apiKey = req.headers.get("x-roblox-api-key");
}

return await handleStatus(apiKey, operationId, assetId);
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
}
}

export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const apiKey = url.searchParams.get("apiKey");
const apiKey = req.headers.get("x-roblox-api-key");
const operationId = url.searchParams.get("operationId");
const assetId = url.searchParams.get("assetId");
return await handleStatus(apiKey, operationId, assetId);
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
}
}

if (!apiKey) {
return NextResponse.json({ ok: false, error: "apiKey wajib diisi" }, { status: 400 });
}
if (!operationId && !assetId) {
return NextResponse.json({ ok: false, error: "operationId atau assetId wajib diisi" }, { status: 400 });
}

// Phase 1: Poll operation status (if operationId provided)
if (operationId) {
const result = await getOperationStatus(apiKey, operationId);
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
async function handleStatus(
apiKey: string | null,
operationId: string | null,
assetId: string | null,
) {
if (!apiKey || !apiKey.trim()) {
return NextResponse.json({ ok: false, error: "apiKey wajib diisi" }, { status: 400 });
}
if (!operationId && !assetId) {
return NextResponse.json({ ok: false, error: "operationId atau assetId wajib diisi" }, { status: 400 });
}

// Phase 2: Check moderation status (if assetId provided)
const result = await getAssetModerationStatus(apiKey, assetId!);
// Phase 1: Poll operation status (if operationId provided)
if (operationId) {
const result = await getOperationStatus(apiKey, operationId);
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
}

// Phase 2: Check moderation status (if assetId provided)
const result = await getAssetModerationStatus(apiKey, assetId!);
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
19 changes: 19 additions & 0 deletions src/app/api/roblox/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { createAudioAsset } from "@/lib/roblox-api";
import { readAudioFile } from "@/lib/audio-processor";
import { rateLimit } from "@/lib/rate-limit";

export const runtime = "nodejs";
export const maxDuration = 60;

/** Hard cap for the in-process audio buffer. Roblox's audio asset limit is
* ~50MB after their own processing, but we allow up to 100MB on disk in the
* extract route. Anything larger than 100MB in a single Buffer is almost
* certainly a bug or a DoS attempt. */
const MAX_AUDIO_BUFFER_BYTES = 100 * 1024 * 1024;

/** POST /api/roblox/upload
* Body: { apiKey, userId, groupId?, fileName, assetName, description? }
* Creates the asset via Open Cloud API and returns immediately with operationId
* (or assetId if completed synchronously). The frontend polls /api/roblox/status
* for operation completion and moderation status.
*/
export async function POST(req: NextRequest) {
if (!rateLimit(req, { key: "roblox-upload", max: 20, windowMs: 60_000 })) {
return NextResponse.json(
{ ok: false, error: "Terlalu banyak percobaan upload. Coba lagi dalam 1 menit." },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
try {
const body = await req.json();
const { apiKey, userId, groupId, fileName, assetName, description } = body;
Expand All @@ -25,6 +38,12 @@ export async function POST(req: NextRequest) {
if (!buf) {
return NextResponse.json({ ok: false, error: "File audio yang diproses tidak ditemukan. Proses ulang audio terlebih dahulu." }, { status: 404 });
}
if (buf.length > MAX_AUDIO_BUFFER_BYTES) {
return NextResponse.json(
{ ok: false, error: `File terlalu besar (${(buf.length / 1024 / 1024).toFixed(1)} MB). Maksimum 100 MB.` },
{ status: 413 },
);
}

const result = await createAudioAsset({
apiKey: apiKey.trim(),
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/roblox/verify/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyRobloxApiKey } from "@/lib/roblox-api";
import { rateLimit } from "@/lib/rate-limit";

export const runtime = "nodejs";

/** POST /api/roblox/verify
* Body: { apiKey: string, userId: number }
* Verifies a Roblox Open Cloud API key and returns the public user profile.
*
* Rate-limited: 10 requests / minute / IP. The verify endpoint hits Roblox
* twice (user lookup + assets probe) so we keep the budget tight to avoid
* our server getting us throttled by Roblox.
*/
export async function POST(req: NextRequest) {
if (!rateLimit(req, { key: "roblox-verify", max: 10, windowMs: 60_000 })) {
return NextResponse.json(
{ ok: false, error: "Terlalu banyak percobaan verifikasi. Coba lagi dalam 1 menit." },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
try {
const { apiKey, userId } = await req.json();
if (!apiKey) return NextResponse.json({ ok: false, error: "API Key wajib diisi" }, { status: 400 });
Expand Down
76 changes: 45 additions & 31 deletions src/components/history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,57 +82,71 @@ export function HistoryList() {
load();
}, [load, historyVersion]);

// Polling for pending/reviewing items
React.useEffect(() => {
const pendingItems = items.filter(
(i) => i.uploadStatus === "uploaded" && i.robloxAssetId && (i.moderationStatus === "Pending" || i.moderationStatus === "Reviewing")
);

if (pendingItems.length === 0 || !account?.apiKey) return;
// Polling for pending/reviewing items. We use refs for `items` and
// `account` so the effect doesn't recreate the interval every time the
// history reloads (which would be wasteful but not technically a leak).
const itemsRef = React.useRef(items);
const accountRef = React.useRef(account);
React.useEffect(() => { itemsRef.current = items; }, [items]);
React.useEffect(() => { accountRef.current = account; }, [account]);

React.useEffect(() => {
const interval = setInterval(async () => {
let updated = false;
const newItems = [...items];
const acc = accountRef.current;
if (!acc?.apiKey) return;
const liveItems = itemsRef.current;
const pendingItems = liveItems.filter(
(i) =>
i.uploadStatus === "uploaded" &&
i.robloxAssetId &&
(i.moderationStatus === "Pending" || i.moderationStatus === "Reviewing"),
);
if (pendingItems.length === 0) return;

for (const item of pendingItems) {
const updates: Array<{ id: string; moderationStatus: string; moderationReason: string | null }> = [];
await Promise.allSettled(pendingItems.map(async (item) => {
try {
const res = await fetch(`/api/roblox/status?apiKey=${encodeURIComponent(account.apiKey)}&assetId=${item.robloxAssetId}`);
const res = await fetch("/api/roblox/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: acc.apiKey, assetId: item.robloxAssetId }),
});
const data = await res.json();

if (data.ok && data.moderationState && data.moderationState !== item.moderationStatus) {
// Update DB
await fetch(`/api/history?id=${item.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
moderationStatus: data.moderationState,
moderationReason: data.moderationReason || null
body: JSON.stringify({
moderationStatus: data.moderationState,
moderationReason: data.moderationReason || null,
}),
}).catch(() => {});
updates.push({
id: item.id,
moderationStatus: data.moderationState,
moderationReason: data.moderationReason || null,
});

// Update local state
const idx = newItems.findIndex((i) => i.id === item.id);
if (idx !== -1) {
newItems[idx] = {
...newItems[idx],
moderationStatus: data.moderationState,
moderationReason: data.moderationReason || null
};
updated = true;
}
}
} catch (e) {
console.error("Failed to poll history item", e);
}
}
}));

if (updated) {
setItems(newItems);
if (updates.length > 0) {
setItems((prev) => {
const map = new Map(updates.map((u) => [u.id, u]));
return prev.map((it) => {
const u = map.get(it.id);
if (!u) return it;
return { ...it, moderationStatus: u.moderationStatus, moderationReason: u.moderationReason };
});
});
}
}, 10000); // Poll every 10s
}, 10000);

return () => clearInterval(interval);
}, [items, account]);
}, []);

const handleDelete = async (id: string) => {
try {
Expand Down
Loading