Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
298 lines
9.8 KiB
TypeScript
298 lines
9.8 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import { z } from "zod";
|
|
import type { Database } from "../shared/database";
|
|
import {
|
|
commentInput,
|
|
communityReportInput,
|
|
communityReviewInput,
|
|
reactionInput,
|
|
} from "../shared/community";
|
|
import { canReviewMaps } from "../shared/permissions";
|
|
import { browserAccount } from "./request-security";
|
|
import { ServiceError, checkServiceError } from "./service-error";
|
|
|
|
type Dependencies = {
|
|
db: SupabaseClient<Database>;
|
|
getUser: () => Promise<{ id: string } | null>;
|
|
getProfile: (id: string) => Promise<{ id: string; role: string }>;
|
|
jsonBody: (request: NextRequest) => Promise<unknown>;
|
|
limit: (key: string, count: number, seconds: number) => Promise<void>;
|
|
};
|
|
const id = (value: string) => z.string().uuid().parse(value);
|
|
const cursorInput = z.tuple([
|
|
z.string().datetime({ offset: true }),
|
|
z.string().uuid(),
|
|
]);
|
|
const cursorFor = (row: { created_at: string; id: string }) =>
|
|
`${row.created_at}|${row.id}`;
|
|
function cursorFilter(request: NextRequest, direction: "lt" | "gt") {
|
|
const value = request.nextUrl.searchParams.get("cursor");
|
|
if (!value) return null;
|
|
const [created, rowId] = cursorInput.parse(value.split("|"));
|
|
return `created_at.${direction}.${created},and(created_at.eq.${created},id.${direction}.${rowId})`;
|
|
}
|
|
function rows<T>(result: {
|
|
data: T;
|
|
error: { message: string; code?: string } | null;
|
|
}): NonNullable<T> {
|
|
checkServiceError(result.error);
|
|
if (result.data == null) throw new ServiceError("Content not found", 404);
|
|
return result.data;
|
|
}
|
|
const respond = (body: unknown, status = 200) =>
|
|
NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
|
|
|
|
export async function communityApi(
|
|
request: NextRequest,
|
|
path: string[],
|
|
{ db, getUser, getProfile, jsonBody, limit }: Dependencies,
|
|
) {
|
|
const method = request.method;
|
|
async function account(staff = false) {
|
|
const current = await browserAccount(request, getUser);
|
|
const profile = await getProfile(current.id);
|
|
if (staff && !canReviewMaps(profile.role))
|
|
throw new ServiceError("Moderator permission required", 403);
|
|
return profile;
|
|
}
|
|
async function published(datasetId: string) {
|
|
rows(
|
|
await db
|
|
.from("datasets")
|
|
.select("id")
|
|
.eq("id", datasetId)
|
|
.eq("state", "published")
|
|
.maybeSingle(),
|
|
);
|
|
}
|
|
if (
|
|
path[0] === "maps" &&
|
|
path[2] === "community" &&
|
|
path.length === 3 &&
|
|
method === "GET"
|
|
) {
|
|
const datasetId = id(path[1]);
|
|
await published(datasetId);
|
|
const current = await getUser();
|
|
const profile = current ? await getProfile(current.id) : null;
|
|
const filter = cursorFilter(request, "lt");
|
|
let commentQuery = db
|
|
.from("map_comments")
|
|
.select("id,body,created_at,author_id")
|
|
.eq("dataset_id", datasetId)
|
|
.is("removed_at", null)
|
|
.order("created_at", { ascending: false })
|
|
.order("id", { ascending: false })
|
|
.limit(31);
|
|
if (filter) commentQuery = commentQuery.or(filter);
|
|
const [summary, comments, reaction] = await Promise.all([
|
|
db.rpc("map_reaction_summary", { p_dataset: datasetId }),
|
|
commentQuery,
|
|
current
|
|
? db
|
|
.from("map_reactions")
|
|
.select("value")
|
|
.eq("dataset_id", datasetId)
|
|
.eq("user_id", current.id)
|
|
.maybeSingle()
|
|
: Promise.resolve({ data: null, error: null }),
|
|
]);
|
|
const items = rows(comments);
|
|
const authors = [
|
|
...new Set(items.flatMap((c) => (c.author_id ? [c.author_id] : []))),
|
|
];
|
|
const profiles = authors.length
|
|
? rows(
|
|
await db
|
|
.from("profiles")
|
|
.select(
|
|
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
|
)
|
|
.in("id", authors),
|
|
)
|
|
: [];
|
|
const byId = new Map(profiles.map((p) => [p.id, p]));
|
|
checkServiceError(reaction.error);
|
|
const totals = rows(summary)[0];
|
|
return respond({
|
|
likes: Number(totals?.likes ?? 0),
|
|
dislikes: Number(totals?.dislikes ?? 0),
|
|
reaction: reaction.data?.value ?? 0,
|
|
viewer: profile
|
|
? { id: profile.id, canModerate: canReviewMaps(profile.role) }
|
|
: null,
|
|
comments: items.slice(0, 30).map((c) => ({
|
|
...c,
|
|
author: byId.get(c.author_id ?? "")?.display_name ?? "Deleted player",
|
|
appearance: byId.get(c.author_id ?? ""),
|
|
})),
|
|
nextCursor: items.length > 30 ? cursorFor(items[29]) : null,
|
|
});
|
|
}
|
|
if (
|
|
path[0] === "maps" &&
|
|
path.length === 3 &&
|
|
["reaction", "comments"].includes(path[2]) &&
|
|
method === "POST"
|
|
) {
|
|
const current = await account();
|
|
const datasetId = id(path[1]);
|
|
await published(datasetId);
|
|
if (path[2] === "reaction") {
|
|
const body = reactionInput.parse(await jsonBody(request));
|
|
await limit("community:reaction:" + current.id, 120, 60);
|
|
const result =
|
|
body.value === 0
|
|
? await db
|
|
.from("map_reactions")
|
|
.delete()
|
|
.eq("dataset_id", datasetId)
|
|
.eq("user_id", current.id)
|
|
: await db.from("map_reactions").upsert(
|
|
{
|
|
dataset_id: datasetId,
|
|
user_id: current.id,
|
|
value: body.value,
|
|
},
|
|
{ onConflict: "dataset_id,user_id" },
|
|
);
|
|
checkServiceError(result.error);
|
|
return respond({ ok: true });
|
|
}
|
|
const body = commentInput.parse(await jsonBody(request));
|
|
await limit("community:comment:" + current.id, 10, 600);
|
|
rows(
|
|
await db
|
|
.from("map_comments")
|
|
.insert({
|
|
dataset_id: datasetId,
|
|
author_id: current.id,
|
|
body: body.body,
|
|
})
|
|
.select("id")
|
|
.single(),
|
|
);
|
|
return respond({ ok: true }, 201);
|
|
}
|
|
if (
|
|
path[0] === "community" &&
|
|
path[1] === "comments" &&
|
|
path.length === 3 &&
|
|
method === "DELETE"
|
|
) {
|
|
const reviewer = await account(true);
|
|
const result = await db
|
|
.from("map_comments")
|
|
.update({ removed_at: new Date().toISOString(), removed_by: reviewer.id })
|
|
.eq("id", id(path[2]))
|
|
.is("removed_at", null)
|
|
.select("id")
|
|
.maybeSingle();
|
|
rows(result);
|
|
return respond({ ok: true });
|
|
}
|
|
if (path.join("/") === "community/reports" && method === "POST") {
|
|
const current = await account();
|
|
const body = communityReportInput.parse(await jsonBody(request));
|
|
await limit("community:report:" + current.id, 10, 3600);
|
|
if (body.target === "comment") {
|
|
const comment = rows(
|
|
await db
|
|
.from("map_comments")
|
|
.select("dataset_id")
|
|
.eq("id", body.targetId)
|
|
.is("removed_at", null)
|
|
.maybeSingle(),
|
|
);
|
|
await published(comment.dataset_id);
|
|
} else {
|
|
if (body.targetId === current.id)
|
|
throw new ServiceError("You cannot report your own account", 400);
|
|
rows(
|
|
await db
|
|
.from("profiles")
|
|
.select("id")
|
|
.eq("id", body.targetId)
|
|
.maybeSingle(),
|
|
);
|
|
}
|
|
const result = await db.from("community_reports").insert({
|
|
reporter_id: current.id,
|
|
target: body.target,
|
|
comment_id: body.target === "comment" ? body.targetId : null,
|
|
account_id: body.target === "account" ? body.targetId : null,
|
|
category: body.category,
|
|
reason: body.reason,
|
|
});
|
|
if (result.error?.code === "23505")
|
|
throw new ServiceError(
|
|
"You already have an open report for this content",
|
|
409,
|
|
);
|
|
checkServiceError(result.error);
|
|
return respond({ ok: true }, 201);
|
|
}
|
|
if (path[0] === "moderation" && path[1] === "community-reports") {
|
|
const reviewer = await account(true);
|
|
if (method === "GET" && path.length === 2) {
|
|
const filter = cursorFilter(request, "gt");
|
|
let query = db
|
|
.from("community_reports")
|
|
.select("*")
|
|
.eq("state", "open")
|
|
.order("created_at")
|
|
.order("id")
|
|
.limit(51);
|
|
if (filter) query = query.or(filter);
|
|
const reports = rows(await query);
|
|
const commentIds = reports.flatMap((r) =>
|
|
r.comment_id ? [r.comment_id] : [],
|
|
);
|
|
const accountIds = reports.flatMap((r) =>
|
|
r.account_id ? [r.account_id] : [],
|
|
);
|
|
const [comments, accounts] = await Promise.all([
|
|
commentIds.length
|
|
? db.from("map_comments").select("*").in("id", commentIds)
|
|
: Promise.resolve({ data: [], error: null }),
|
|
accountIds.length
|
|
? db.from("profiles").select("id,display_name").in("id", accountIds)
|
|
: Promise.resolve({ data: [], error: null }),
|
|
]);
|
|
const byComment = new Map(rows(comments).map((c) => [c.id, c]));
|
|
const byAccount = new Map(
|
|
rows(accounts).map((a) => [a.id, a.display_name]),
|
|
);
|
|
return respond({
|
|
reports: reports.slice(0, 50).map((r) => ({
|
|
...r,
|
|
comment: byComment.get(r.comment_id ?? "") ?? null,
|
|
accountName: byAccount.get(r.account_id ?? "") ?? null,
|
|
})),
|
|
nextCursor: reports.length > 50 ? cursorFor(reports[49]) : null,
|
|
});
|
|
}
|
|
if (method === "PATCH" && path.length === 3) {
|
|
const body = communityReviewInput.parse(await jsonBody(request));
|
|
const result = await db
|
|
.from("community_reports")
|
|
.update({
|
|
state: body.decision,
|
|
reviewer_id: reviewer.id,
|
|
reviewed_at: new Date().toISOString(),
|
|
review_reason: body.reason,
|
|
})
|
|
.eq("id", id(path[2]))
|
|
.eq("state", "open")
|
|
.select("id")
|
|
.maybeSingle();
|
|
checkServiceError(result.error);
|
|
if (!result.data)
|
|
throw new ServiceError("Report already reviewed or not found", 409);
|
|
return respond({ ok: true });
|
|
}
|
|
}
|
|
return respond({ error: "Not found" }, 404);
|
|
}
|