The first commit, the core and all files is adding to repo
This commit is contained in:
671
lib/api.ts
Normal file
671
lib/api.ts
Normal file
@@ -0,0 +1,671 @@
|
||||
import "server-only";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { admin, required, user } from "./supabase";
|
||||
import { datasetSchema, faces, difficulties } from "../shared/protocol";
|
||||
import { mediaUrl } from "./catalog";
|
||||
const hash = (value: string) =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
const idSchema = z.string().uuid();
|
||||
const uuid = (value: string) => idSchema.parse(value);
|
||||
function cookieSecret(request: NextRequest) {
|
||||
return request.cookies.get("mg_guest")?.value ?? "";
|
||||
}
|
||||
async function contributor(request: NextRequest) {
|
||||
const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
|
||||
let owner: string | undefined;
|
||||
if (token) {
|
||||
const row = required(
|
||||
await admin()
|
||||
.from("contributor_tokens")
|
||||
.select("owner_id")
|
||||
.eq("token_hash", hash(token))
|
||||
.eq("revoked", false)
|
||||
.single(),
|
||||
);
|
||||
owner = row.owner_id;
|
||||
} else owner = (await user())?.id;
|
||||
if (!owner) throw new Error("Sign in to continue");
|
||||
const { error } = await admin()
|
||||
.from("profiles")
|
||||
.upsert({ id: owner }, { onConflict: "id", ignoreDuplicates: true });
|
||||
if (error) throw new Error(error.message);
|
||||
return owner;
|
||||
}
|
||||
async function moderator(request: NextRequest) {
|
||||
const owner = await contributor(request);
|
||||
const profile = required(
|
||||
await admin().from("profiles").select("role").eq("id", owner).single(),
|
||||
);
|
||||
if (profile.role !== "moderator")
|
||||
throw new Error("Moderator permission required");
|
||||
return owner;
|
||||
}
|
||||
async function limit(key: string, count: number, seconds: number) {
|
||||
const allowed = required(
|
||||
await admin().rpc("check_rate", {
|
||||
p_key: key,
|
||||
p_limit: count,
|
||||
p_seconds: seconds,
|
||||
}),
|
||||
);
|
||||
if (!allowed)
|
||||
throw new Error("Too many requests. Please wait and try again.");
|
||||
}
|
||||
async function ownedDataset(request: NextRequest, id: string) {
|
||||
const owner = await contributor(request);
|
||||
return required(
|
||||
await admin()
|
||||
.from("datasets")
|
||||
.select("*")
|
||||
.eq("id", uuid(id))
|
||||
.eq("owner_id", owner)
|
||||
.single(),
|
||||
);
|
||||
}
|
||||
async function session(request: NextRequest, id: string) {
|
||||
const record = required(
|
||||
await admin().from("game_sessions").select("*").eq("id", uuid(id)).single(),
|
||||
);
|
||||
const secret = cookieSecret(request);
|
||||
if (secret && record.access_hash === hash(secret)) return record;
|
||||
const account = await user();
|
||||
if (account && record.owner_id === account.id) return record;
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
async function jsonBody(request: NextRequest) {
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) throw new Error("JSON request required");
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > 2 * 1024 * 1024) {
|
||||
await reader.cancel();
|
||||
throw new Error("Request too large");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
function verifyOrigin(request: NextRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return;
|
||||
const origin = request.headers.get("origin");
|
||||
const allowed = new Set([
|
||||
new URL(request.url).origin,
|
||||
process.env.MINEGEO_URL || "http://localhost:3000",
|
||||
]);
|
||||
if (origin && !allowed.has(origin)) throw new Error("Invalid request origin");
|
||||
if (
|
||||
!origin &&
|
||||
!request.headers.get("authorization") &&
|
||||
!request.headers.get("content-type")?.includes("application/json")
|
||||
)
|
||||
throw new Error("JSON request required");
|
||||
}
|
||||
export async function handle(request: NextRequest, path: string[]) {
|
||||
try {
|
||||
verifyOrigin(request);
|
||||
const client = admin(),
|
||||
method = request.method;
|
||||
if (path[0] === "health") {
|
||||
const { error } = await client
|
||||
.from("catalog_servers")
|
||||
.select("id")
|
||||
.limit(1);
|
||||
return NextResponse.json(
|
||||
{ ok: !error, version: "0.1.0" },
|
||||
{ status: error ? 503 : 200 },
|
||||
);
|
||||
}
|
||||
if (method === "GET" && path[0] === "catalog") {
|
||||
const rows = required(
|
||||
await client.from("datasets").select("*").eq("state", "published"),
|
||||
);
|
||||
return NextResponse.json({
|
||||
maps: await Promise.all(
|
||||
rows.map(async (m) => ({
|
||||
...m,
|
||||
image: await mediaUrl(m.cover_path),
|
||||
})),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (path[0] === "sessions") {
|
||||
if (method === "POST" && path.length === 1) {
|
||||
const body = z
|
||||
.object({
|
||||
datasetId: idSchema,
|
||||
difficulty: z.enum(["easy", "normal", "hard"]),
|
||||
})
|
||||
.parse(await jsonBody(request));
|
||||
const secret = cookieSecret(request) || randomBytes(32).toString("hex");
|
||||
await limit("session:" + hash(secret), 20, 3600);
|
||||
const account = await user();
|
||||
const id = required(
|
||||
await client.rpc("create_game", {
|
||||
p_dataset: body.datasetId,
|
||||
p_difficulty: body.difficulty,
|
||||
p_owner: account?.id ?? null,
|
||||
p_access_hash: hash(secret),
|
||||
}),
|
||||
);
|
||||
const response = NextResponse.json({ id });
|
||||
response.cookies.set("mg_guest", secret, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(request.url).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const s = await session(request, path[1]);
|
||||
if (method === "GET") {
|
||||
const revision = required(
|
||||
await client
|
||||
.from("revisions")
|
||||
.select("*")
|
||||
.eq("id", s.revision_id)
|
||||
.single(),
|
||||
);
|
||||
const manifest = datasetSchema.parse(revision.manifest);
|
||||
const rounds = required(
|
||||
await client
|
||||
.from("game_rounds")
|
||||
.select("*")
|
||||
.eq("session_id", s.id)
|
||||
.order("round_number"),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: s.id,
|
||||
difficulty: s.difficulty,
|
||||
round: s.current_round,
|
||||
bounds: manifest.bounds,
|
||||
name: manifest.name,
|
||||
map: await mediaUrl(`${revision.asset_root}/map.webp`),
|
||||
rounds: await Promise.all(
|
||||
rounds.map(async (r) => ({
|
||||
number: r.round_number,
|
||||
deadline: r.deadline,
|
||||
result: r.result,
|
||||
faces: await Promise.all(
|
||||
faces.map((f) =>
|
||||
mediaUrl(
|
||||
`${revision.asset_root}/${z.object({ id: z.string() }).parse(r.viewpoint).id}/${f}.webp`,
|
||||
),
|
||||
),
|
||||
),
|
||||
})),
|
||||
),
|
||||
serverNow: Date.now(),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
if (method === "POST" && path[2] === "activate") {
|
||||
const body = z
|
||||
.object({ round: z.number().int().min(0).max(4) })
|
||||
.parse(await jsonBody(request));
|
||||
const deadline = required(
|
||||
await client.rpc("activate_round", {
|
||||
p_session: s.id,
|
||||
p_round: body.round,
|
||||
p_access_hash: s.access_hash,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json({ deadline, serverNow: Date.now() });
|
||||
}
|
||||
if (method === "POST" && path[2] === "guess") {
|
||||
const body = z
|
||||
.object({
|
||||
round: z.number().int().min(0).max(4),
|
||||
x: z.number().finite().optional(),
|
||||
z: z.number().finite().optional(),
|
||||
})
|
||||
.parse(await jsonBody(request));
|
||||
const result = required(
|
||||
await client.rpc("submit_guess", {
|
||||
p_session: s.id,
|
||||
p_round: body.round,
|
||||
p_access_hash: s.access_hash,
|
||||
p_x: body.x ?? null,
|
||||
p_z: body.z ?? null,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
}
|
||||
if (path[0] === "uploads") {
|
||||
const owner = await contributor(request);
|
||||
if (method === "POST" && path.length === 1) {
|
||||
await limit("upload:" + owner, 10, 3600);
|
||||
const manifest = datasetSchema.parse(
|
||||
(await jsonBody(request)).manifest,
|
||||
);
|
||||
if (
|
||||
manifest.assets.reduce((n, a) => n + a.bytes, 0) >
|
||||
Number(process.env.MINEGEO_MAX_UPLOAD_BYTES || 268435456)
|
||||
)
|
||||
throw new Error("Dataset exceeds upload limit");
|
||||
const { data: existing } = await client
|
||||
.from("catalog_servers")
|
||||
.select("id")
|
||||
.eq("name", manifest.serverName)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
const serverId =
|
||||
existing?.id ??
|
||||
required(
|
||||
await client
|
||||
.from("catalog_servers")
|
||||
.insert({
|
||||
name: manifest.serverName,
|
||||
address: manifest.serverAddress ?? null,
|
||||
})
|
||||
.select("id")
|
||||
.single(),
|
||||
).id;
|
||||
const dataset = required(
|
||||
await client
|
||||
.from("datasets")
|
||||
.insert({
|
||||
owner_id: owner,
|
||||
server_id: serverId,
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
source: manifest.source,
|
||||
bounds: manifest.bounds,
|
||||
})
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
required(
|
||||
await client
|
||||
.from("dataset_inputs")
|
||||
.insert({ dataset_id: dataset.id, manifest })
|
||||
.select("dataset_id")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({ datasetId: dataset.id });
|
||||
}
|
||||
const body = z
|
||||
.object({ datasetId: idSchema })
|
||||
.parse(await jsonBody(request));
|
||||
const d = await ownedDataset(request, body.datasetId);
|
||||
if (method === "POST" && path[1] === "sign") {
|
||||
if (d.state !== "uploading")
|
||||
throw new Error("Dataset no longer accepts uploads");
|
||||
const input = required(
|
||||
await client
|
||||
.from("dataset_inputs")
|
||||
.select("manifest")
|
||||
.eq("dataset_id", d.id)
|
||||
.single(),
|
||||
);
|
||||
const endpoint = new URL(
|
||||
"/storage/v1/upload/resumable",
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
);
|
||||
endpoint.hostname = endpoint.hostname.replace(
|
||||
".supabase.co",
|
||||
".storage.supabase.co",
|
||||
);
|
||||
const assets = await Promise.all(
|
||||
datasetSchema
|
||||
.parse(input.manifest)
|
||||
.assets.map(async (a: { path: string }) => {
|
||||
const objectName = `${owner}/${d.id}/${a.path}`;
|
||||
const signed = required(
|
||||
await client.storage
|
||||
.from("minegeo-raw")
|
||||
.createSignedUploadUrl(objectName),
|
||||
);
|
||||
return {
|
||||
path: a.path,
|
||||
objectName,
|
||||
token: signed.token,
|
||||
endpoint: endpoint.toString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return NextResponse.json({ assets });
|
||||
}
|
||||
if (method === "POST" && path[1] === "finalize") {
|
||||
const state = required(
|
||||
await client.rpc("finalize_upload", {
|
||||
p_dataset: d.id,
|
||||
p_owner: owner,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json({ datasetId: d.id, state });
|
||||
}
|
||||
}
|
||||
if (path[0] === "datasets") {
|
||||
if (method === "GET" && path.length === 1) {
|
||||
const owner = await contributor(request);
|
||||
const maps = required(
|
||||
await client
|
||||
.from("datasets")
|
||||
.select("*, upload_jobs(state,progress,error)")
|
||||
.eq("owner_id", owner)
|
||||
.order("created_at", { ascending: false }),
|
||||
);
|
||||
return NextResponse.json({
|
||||
maps: await Promise.all(
|
||||
maps.map(async (m) => ({
|
||||
...m,
|
||||
image: await mediaUrl(m.cover_path),
|
||||
map: await mediaUrl(m.map_path),
|
||||
})),
|
||||
),
|
||||
});
|
||||
}
|
||||
const d = await ownedDataset(request, path[1]);
|
||||
if (method === "GET") {
|
||||
const input = required(
|
||||
await client
|
||||
.from("dataset_inputs")
|
||||
.select("manifest")
|
||||
.eq("dataset_id", d.id)
|
||||
.single(),
|
||||
);
|
||||
const manifest = datasetSchema.parse(input.manifest);
|
||||
return NextResponse.json({
|
||||
...d,
|
||||
manifest,
|
||||
map: await mediaUrl(d.map_path),
|
||||
previews: await Promise.all(
|
||||
manifest.viewpoints.map(async (p: { id: string }) => ({
|
||||
id: p.id,
|
||||
image: await mediaUrl(`${d.id}/${p.id}/preview.webp`),
|
||||
})),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (method === "PATCH") {
|
||||
if (!["draft", "rejected"].includes(d.state))
|
||||
throw new Error("Only private drafts can be edited");
|
||||
const body = z
|
||||
.object({
|
||||
name: z.string().min(3).max(80),
|
||||
description: z.string().max(2000),
|
||||
selectedIds: z.array(z.string()).min(5).optional(),
|
||||
})
|
||||
.parse(await jsonBody(request));
|
||||
const input = required(
|
||||
await client
|
||||
.from("dataset_inputs")
|
||||
.select("manifest")
|
||||
.eq("dataset_id", d.id)
|
||||
.single(),
|
||||
);
|
||||
let manifest = {
|
||||
...datasetSchema.parse(input.manifest),
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
};
|
||||
if (body.selectedIds)
|
||||
manifest.viewpoints = manifest.viewpoints.filter(
|
||||
(p: { id: string }) => body.selectedIds!.includes(p.id),
|
||||
);
|
||||
manifest = datasetSchema.parse(manifest);
|
||||
required(
|
||||
await client
|
||||
.from("dataset_inputs")
|
||||
.update({ manifest })
|
||||
.eq("dataset_id", d.id)
|
||||
.select("dataset_id")
|
||||
.single(),
|
||||
);
|
||||
required(
|
||||
await client
|
||||
.from("datasets")
|
||||
.update({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
panorama_count: manifest.viewpoints.length,
|
||||
state: "draft",
|
||||
rejection_reason: null,
|
||||
})
|
||||
.eq("id", d.id)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "POST" && path[2] === "submit") {
|
||||
if (!["draft", "rejected"].includes(d.state) || d.panorama_count < 5)
|
||||
throw new Error(
|
||||
"Need a processed draft with at least five panoramas",
|
||||
);
|
||||
required(
|
||||
await client
|
||||
.from("datasets")
|
||||
.update({ state: "review" })
|
||||
.eq("id", d.id)
|
||||
.in("state", ["draft", "rejected"])
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "POST" && path[2] === "cancel") {
|
||||
required(
|
||||
await client
|
||||
.from("upload_jobs")
|
||||
.update({ state: "cancelled" })
|
||||
.eq("dataset_id", d.id)
|
||||
.in("state", ["queued", "running"])
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
await client
|
||||
.from("datasets")
|
||||
.update({ state: "failed", rejection_reason: "Processing cancelled" })
|
||||
.eq("id", d.id)
|
||||
.in("state", ["uploading", "processing"]);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
if (path[0] === "tokens") {
|
||||
const owner = await contributor(request);
|
||||
if (method === "GET")
|
||||
return NextResponse.json({
|
||||
tokens: required(
|
||||
await client
|
||||
.from("contributor_tokens")
|
||||
.select("id,label,revoked,created_at")
|
||||
.eq("owner_id", owner),
|
||||
),
|
||||
});
|
||||
if (method === "POST") {
|
||||
const label = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.parse((await jsonBody(request)).label);
|
||||
await limit("tokens:" + owner, 20, 3600);
|
||||
const token = "mg_" + randomBytes(32).toString("hex");
|
||||
required(
|
||||
await client
|
||||
.from("contributor_tokens")
|
||||
.insert({ owner_id: owner, token_hash: hash(token), label })
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
if (method === "DELETE") {
|
||||
required(
|
||||
await client
|
||||
.from("contributor_tokens")
|
||||
.update({ revoked: true })
|
||||
.eq("id", uuid(path[1]))
|
||||
.eq("owner_id", owner)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
if (path[0] === "pairing") {
|
||||
if (method === "POST" && path.length === 1) {
|
||||
const deviceCode = randomBytes(32).toString("hex"),
|
||||
code = randomBytes(4).toString("hex").toUpperCase();
|
||||
await limit(
|
||||
"pairing:" +
|
||||
hash(request.headers.get("x-forwarded-for") || "unknown"),
|
||||
30,
|
||||
600,
|
||||
);
|
||||
required(
|
||||
await client
|
||||
.from("pairing_codes")
|
||||
.insert({
|
||||
device_hash: hash(deviceCode),
|
||||
user_code: code,
|
||||
expires_at: new Date(Date.now() + 600000).toISOString(),
|
||||
})
|
||||
.select("user_code")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({
|
||||
deviceCode,
|
||||
userCode: code,
|
||||
verificationUri: `${process.env.MINEGEO_URL || new URL(request.url).origin}/pair?code=${code}`,
|
||||
interval: 5,
|
||||
});
|
||||
}
|
||||
if (method === "POST" && path[1] === "approve") {
|
||||
const owner = await contributor(request);
|
||||
const code = z
|
||||
.string()
|
||||
.regex(/^[A-F0-9]{8}$/)
|
||||
.parse((await jsonBody(request)).code);
|
||||
const token = "mg_" + randomBytes(32).toString("hex");
|
||||
const { error } = await client.rpc("approve_pairing", {
|
||||
p_code: code,
|
||||
p_owner: owner,
|
||||
p_token: token,
|
||||
p_hash: hash(token),
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "POST" && path[1] === "poll") {
|
||||
const code = z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.parse((await jsonBody(request)).deviceCode);
|
||||
await limit("poll:" + hash(code), 12, 60);
|
||||
const row = required(
|
||||
await client
|
||||
.from("pairing_codes")
|
||||
.select("*")
|
||||
.eq("device_hash", hash(code))
|
||||
.gt("expires_at", new Date().toISOString())
|
||||
.eq("consumed", false)
|
||||
.single(),
|
||||
);
|
||||
if (!row.token_value) return NextResponse.json({ status: "pending" });
|
||||
required(
|
||||
await client
|
||||
.from("pairing_codes")
|
||||
.update({ consumed: true, token_value: null })
|
||||
.eq("device_hash", hash(code))
|
||||
.eq("consumed", false)
|
||||
.select("device_hash")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({
|
||||
status: "approved",
|
||||
token: row.token_value,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (path[0] === "moderation") {
|
||||
const reviewer = await moderator(request);
|
||||
if (method === "GET") {
|
||||
const maps = required(
|
||||
await client
|
||||
.from("datasets")
|
||||
.select("*")
|
||||
.in("state", ["review", "published"])
|
||||
.order("created_at"),
|
||||
);
|
||||
const reports = required(
|
||||
await client.from("reports").select("*").eq("state", "open"),
|
||||
);
|
||||
return NextResponse.json({
|
||||
maps: await Promise.all(
|
||||
maps.map(async (m) => ({
|
||||
...m,
|
||||
image: await mediaUrl(m.cover_path),
|
||||
map: await mediaUrl(m.map_path),
|
||||
})),
|
||||
),
|
||||
reports,
|
||||
});
|
||||
}
|
||||
if (method === "POST") {
|
||||
const b = z
|
||||
.object({
|
||||
datasetId: idSchema,
|
||||
decision: z.enum(["approve", "reject", "unpublish"]),
|
||||
reason: z.string().max(2000).optional(),
|
||||
})
|
||||
.parse(await jsonBody(request));
|
||||
if (b.decision === "reject" && !b.reason?.trim())
|
||||
throw new Error("Provide a rejection reason");
|
||||
const {error}=await client.rpc("publish_dataset", {
|
||||
p_dataset: b.datasetId,
|
||||
p_reviewer: reviewer,
|
||||
p_decision: b.decision,
|
||||
p_reason: b.reason ?? null,
|
||||
});
|
||||
if(error)throw new Error(error.message);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
if (path[0] === "reports" && method === "POST") {
|
||||
const owner = await contributor(request);
|
||||
await limit("reports:" + owner, 10, 3600);
|
||||
const b = z
|
||||
.object({ datasetId: idSchema, reason: z.string().min(10).max(2000) })
|
||||
.parse(await jsonBody(request));
|
||||
required(
|
||||
await client
|
||||
.from("reports")
|
||||
.insert({
|
||||
dataset_id: b.datasetId,
|
||||
owner_id: owner,
|
||||
reason: b.reason,
|
||||
})
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof z.ZodError
|
||||
? "Invalid request: " + error.issues.map((i) => i.message).join("; ")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Request failed";
|
||||
const status = message.includes("Sign in")
|
||||
? 401
|
||||
: message.includes("permission") || message.includes("origin")
|
||||
? 403
|
||||
: message.includes("Too many")
|
||||
? 429
|
||||
: 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user