rhythia-api 242.0.0 → 244.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/api/createBeatmap.ts +64 -40
  2. package/api/editProfile.ts +4 -67
  3. package/api/executeAdminOperation.ts +637 -27
  4. package/api/getAvatarUploadUrl.ts +90 -85
  5. package/api/getBeatmapPage.ts +2 -0
  6. package/api/getBeatmapPageById.ts +2 -0
  7. package/api/getBeatmaps.ts +110 -197
  8. package/api/getChangelog.ts +46 -0
  9. package/api/getCollection.ts +44 -31
  10. package/api/getMapUploadUrl.ts +90 -93
  11. package/api/getProfile.ts +297 -297
  12. package/api/getScore.ts +2 -0
  13. package/api/getVideoUploadUrl.ts +90 -85
  14. package/api/submitScoreInternal.ts +506 -461
  15. package/api/updateBeatmapPage.ts +6 -0
  16. package/beatmap-file-urls.json +29398 -0
  17. package/handleApi.ts +7 -4
  18. package/index.ts +193 -162
  19. package/package.json +7 -3
  20. package/queries/admin_delete_user.sql +42 -39
  21. package/queries/admin_remove_all_scores.sql +6 -3
  22. package/queries/admin_remove_score.sql +107 -0
  23. package/queries/admin_update_profile.sql +22 -0
  24. package/queries/get_beatmaps_v2.sql +48 -0
  25. package/queries/get_top_scores_for_beatmap3.sql +47 -38
  26. package/queries/profile_update_guards.sql +66 -0
  27. package/supabase/.temp/cli-latest +1 -0
  28. package/supabase/.temp/linked-project.json +1 -0
  29. package/types/database.ts +1702 -1450
  30. package/utils/beatmapFiles.ts +102 -0
  31. package/utils/beatmapHash.ts +239 -0
  32. package/utils/beatmapTopScores.ts +68 -84
  33. package/utils/getUserBySession.ts +3 -1
  34. package/utils/moderation.ts +101 -0
  35. package/utils/profileUpdateValidation.ts +51 -0
  36. package/utils/redis.ts +24 -0
  37. package/utils/requestUtils.ts +2 -2
  38. package/utils/rhrReplay.ts +122 -0
  39. package/utils/star-calc/formatSingle.ts +107 -0
  40. package/utils/star-calc/rhmParser.ts +214 -0
  41. package/utils/star-calc/sspmParser.ts +294 -160
  42. package/worker.ts +197 -195
  43. package/.env +0 -1
@@ -1,11 +1,22 @@
1
1
  import { NextResponse } from "../utils/response";
2
2
  import z from "zod";
3
3
  import { Database } from "../types/database";
4
- import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { protectedApi } from "../utils/requestUtils";
5
5
  import { supabase } from "../utils/supabase";
6
6
  import { getUserBySession } from "../utils/getUserBySession";
7
7
  import { User } from "@supabase/supabase-js";
8
- import { invalidateCache, invalidateCachePrefix } from "../utils/cache";
8
+ import { invalidateCachePrefix } from "../utils/cache";
9
+ import { normalizeProfileUpdateData } from "../utils/profileUpdateValidation";
10
+ import { getModerationState } from "../utils/moderation";
11
+ import {
12
+ GetObjectCommand,
13
+ PutObjectCommand,
14
+ S3Client,
15
+ } from "@aws-sdk/client-s3";
16
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
17
+ import { getRedis } from "../utils/redis";
18
+ import { parseReplaySubmitData } from "../utils/rhrReplay";
19
+ import { submitScoreForUser } from "./submitScoreInternal";
9
20
 
10
21
  type AdminProfileSummary = Pick<
11
22
  Database["public"]["Tables"]["profiles"]["Row"],
@@ -31,11 +42,134 @@ type LinkedProfileAggregate = AdminProfileSummary &
31
42
  const MULTIACCOUNT_PROFILE_SELECT =
32
43
  "id, username, avatar_url, flag, badges, ban";
33
44
 
34
- const SCORE_CACHE_READ_OPERATIONS = new Set([
45
+ const ADMIN_READ_OPERATIONS = new Set([
46
+ "searchUsers",
35
47
  "getScoresPaginated",
48
+ "getFriendLinks",
36
49
  "getMultiaccountInvestigation",
50
+ "getModeratedUsersPaginated",
51
+ "getUserViolationState",
52
+ "downloadACBuild",
53
+ "retrieveACBuilds",
37
54
  ]);
38
55
 
56
+ const AC_BUILD_ADMIN_IDS = new Set([0, 13]);
57
+ const AC_BUILD_OPERATIONS = new Set([
58
+ "uploadACBuild",
59
+ "downloadACBuild",
60
+ "retrieveACBuilds",
61
+ "makeACBuildPrimary",
62
+ "setACBuildActive",
63
+ ]);
64
+
65
+ const acBuildBucket = "ac-builds";
66
+ const acBuildS3Endpoint = "https://s3.eu-central-003.backblazeb2.com";
67
+ const acBuildS3Client = new S3Client({
68
+ region: "eu-central-003",
69
+ endpoint: acBuildS3Endpoint,
70
+ credentials: {
71
+ secretAccessKey: process.env.B2_AC_SECRET_BUCKET || "",
72
+ accessKeyId: process.env.B2_AC_ACCESS_BUCKET || "",
73
+ },
74
+ requestChecksumCalculation: "WHEN_REQUIRED",
75
+ });
76
+
77
+ const acBuildGameBranch = z.enum(["test", "main"]);
78
+ const changelogType = z.enum(["public", "testing", "web"]);
79
+ function clampWebhookText(value: string, maxLength: number) {
80
+ const sanitized = value.replace(/[\u0000-\u001F\u007F]/g, "");
81
+ return sanitized.length <= maxLength
82
+ ? sanitized
83
+ : `${sanitized.slice(0, maxLength - 3)}...`;
84
+ }
85
+
86
+ function getAdminActionDetails(operation: string, params: any) {
87
+ return operation === "addScoreViaReplay"
88
+ ? { userId: params.userId, replayBytes: "<Long>" }
89
+ : params;
90
+ }
91
+
92
+ async function postStaffAdminWebhook({
93
+ admin,
94
+ operation,
95
+ targetUserId,
96
+ details,
97
+ error,
98
+ }: {
99
+ admin: Database["public"]["Tables"]["profiles"]["Row"];
100
+ operation: string;
101
+ targetUserId: number | null;
102
+ details: any;
103
+ error?: string;
104
+ }) {
105
+ const webhookUrl = process.env.WEBHOOK_STAFF_DISCORD;
106
+ if (!webhookUrl) {
107
+ console.log("WEBHOOK_STAFF_DISCORD is not configured");
108
+ return;
109
+ }
110
+
111
+ try {
112
+ const adminName = admin.username || `User #${admin.id}`;
113
+ const payload = {
114
+ content: `Admin action: ${operation}`,
115
+ embeds: [
116
+ {
117
+ title: "Admin Action",
118
+ color: error ? 0xe74c3c : 0x3498db,
119
+ fields: [
120
+ {
121
+ name: "Moderator",
122
+ value: clampWebhookText(`${adminName} (#${admin.id})`, 1024),
123
+ inline: true,
124
+ },
125
+ {
126
+ name: "Action",
127
+ value: clampWebhookText(operation, 1024),
128
+ inline: true,
129
+ },
130
+ {
131
+ name: "Target",
132
+ value: targetUserId === null ? "-" : `#${targetUserId}`,
133
+ inline: true,
134
+ },
135
+ {
136
+ name: "Status",
137
+ value: error ? `Failed: ${clampWebhookText(error, 900)}` : "Succeeded",
138
+ },
139
+ {
140
+ name: "Details",
141
+ value: clampWebhookText(JSON.stringify(details), 1024) || "-",
142
+ },
143
+ ],
144
+ footer: {
145
+ text: new Date().toUTCString(),
146
+ },
147
+ },
148
+ ],
149
+ };
150
+
151
+ const response = await fetch(webhookUrl, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ },
156
+ body: JSON.stringify(payload),
157
+ });
158
+
159
+ if (!response.ok) {
160
+ const responseBody = await response.text();
161
+ console.log("Staff admin webhook failed", {
162
+ operation,
163
+ status: response.status,
164
+ statusText: response.statusText,
165
+ responseBody: clampWebhookText(responseBody || "-", 4000),
166
+ });
167
+ }
168
+ } catch (error) {
169
+ console.log("Failed to post staff admin webhook", error);
170
+ }
171
+ }
172
+
39
173
  function timestampOrZero(value: string | null) {
40
174
  return value ? new Date(value).getTime() : 0;
41
175
  }
@@ -57,21 +191,71 @@ function updateDateRange(
57
191
  }
58
192
  }
59
193
 
194
+ async function setPrimaryACBuild(buildId: number) {
195
+ const { data: build, error: buildError } = await supabase
196
+ .from("ac_builds")
197
+ .select("id,platform,game_branch")
198
+ .eq("id", buildId)
199
+ .single();
200
+
201
+ if (buildError) {
202
+ return { data: null, error: buildError };
203
+ }
204
+
205
+ const inactiveBuilds = await supabase
206
+ .from("ac_builds")
207
+ .update({ active: false })
208
+ .eq("platform", build.platform)
209
+ .eq("game_branch", build.game_branch);
210
+
211
+ return inactiveBuilds.error
212
+ ? inactiveBuilds
213
+ : supabase
214
+ .from("ac_builds")
215
+ .update({ active: true })
216
+ .eq("id", buildId)
217
+ .select("*")
218
+ .single();
219
+ }
220
+
60
221
  // Define supported admin operations and their parameter types
61
222
  const adminOperations = {
62
223
  deleteUser: z.object({ userId: z.number() }),
224
+ updateProfile: z.object({
225
+ userId: z.number(),
226
+ avatar_url: z.string().optional(),
227
+ flag: z.string().optional(),
228
+ profile_image: z.string().optional(),
229
+ username: z.string().optional(),
230
+ verified: z.boolean().optional(),
231
+ }),
63
232
  changeFlag: z.object({ userId: z.number(), flag: z.string() }),
64
- changeBadges: z.object({ userId: z.number(), badges: z.string() }),
233
+ changeBadges: z.object({ userId: z.number(), badges: z.array(z.string()) }),
65
234
  addBadge: z.object({ userId: z.number(), badge: z.string() }),
66
235
  removeBadge: z.object({ userId: z.number(), badge: z.string() }),
67
- excludeUser: z.object({ userId: z.number() }),
68
- restrictUser: z.object({ userId: z.number() }),
69
- silenceUser: z.object({ userId: z.number() }),
236
+ excludeUser: z.object({ userId: z.number(), reason: z.string() }),
237
+ restrictUser: z.object({ userId: z.number(), reason: z.string() }),
238
+ silenceUser: z.object({
239
+ userId: z.number(),
240
+ expiresAt: z.string().nullable().optional(),
241
+ reason: z.string(),
242
+ }),
70
243
  profanityClear: z.object({ userId: z.number() }),
71
244
  searchUsers: z.object({ searchText: z.string() }),
245
+ getFriendLinks: z.object({ userId: z.number() }),
72
246
  removeAllScores: z.object({ userId: z.number() }),
247
+ removeScore: z.object({ userId: z.number(), scoreId: z.number() }),
248
+ addScoreViaReplay: z.object({ userId: z.number(), replayBytes: z.string() }),
73
249
  invalidateRankedScores: z.object({ userId: z.number() }),
74
250
  unbanUser: z.object({ userId: z.number() }),
251
+ revokeViolation: z.object({
252
+ violationId: z.number(),
253
+ reason: z.string().optional(),
254
+ }),
255
+ getModeratedUsersPaginated: z.object({
256
+ page: z.number().min(1).default(1),
257
+ limit: z.number().min(1).max(100).default(25),
258
+ }),
75
259
  getScoresPaginated: z.object({
76
260
  page: z.number().min(1).default(1),
77
261
  limit: z.number().min(1).max(100).default(50),
@@ -79,6 +263,30 @@ const adminOperations = {
79
263
  includeAdditionalData: z.boolean().default(true),
80
264
  }),
81
265
  getMultiaccountInvestigation: z.object({ userId: z.number() }),
266
+ getUserViolationState: z.object({ userId: z.number() }),
267
+ uploadACBuild: z.object({
268
+ contentLength: z.number(),
269
+ contentType: z.string().default("application/octet-stream"),
270
+ gameBranch: acBuildGameBranch,
271
+ hash: z.string(),
272
+ platform: z.string(),
273
+ }),
274
+ retrieveACBuilds: z.object({
275
+ gameBranch: acBuildGameBranch.optional(),
276
+ platform: z.string().optional(),
277
+ }),
278
+ downloadACBuild: z.object({ buildId: z.number() }),
279
+ makeACBuildPrimary: z.object({ buildId: z.number() }),
280
+ setACBuildActive: z.object({ buildId: z.number(), active: z.boolean() }),
281
+ addChangelog: z.object({
282
+ type: changelogType,
283
+ date: z.string(),
284
+ markdown: z.string(),
285
+ }),
286
+ removeChangelog: z.object({
287
+ type: changelogType,
288
+ date: z.string(),
289
+ }),
82
290
  } as const;
83
291
 
84
292
  // Create a discriminated union type for operation parameters
@@ -119,6 +327,14 @@ const OperationParam = z.discriminatedUnion("operation", [
119
327
  operation: z.literal("unbanUser"),
120
328
  params: adminOperations.unbanUser,
121
329
  }),
330
+ z.object({
331
+ operation: z.literal("revokeViolation"),
332
+ params: adminOperations.revokeViolation,
333
+ }),
334
+ z.object({
335
+ operation: z.literal("updateProfile"),
336
+ params: adminOperations.updateProfile,
337
+ }),
122
338
  z.object({
123
339
  operation: z.literal("changeFlag"),
124
340
  params: adminOperations.changeFlag,
@@ -135,6 +351,22 @@ const OperationParam = z.discriminatedUnion("operation", [
135
351
  operation: z.literal("removeBadge"),
136
352
  params: adminOperations.removeBadge,
137
353
  }),
354
+ z.object({
355
+ operation: z.literal("getFriendLinks"),
356
+ params: adminOperations.getFriendLinks,
357
+ }),
358
+ z.object({
359
+ operation: z.literal("removeScore"),
360
+ params: adminOperations.removeScore,
361
+ }),
362
+ z.object({
363
+ operation: z.literal("addScoreViaReplay"),
364
+ params: adminOperations.addScoreViaReplay,
365
+ }),
366
+ z.object({
367
+ operation: z.literal("getModeratedUsersPaginated"),
368
+ params: adminOperations.getModeratedUsersPaginated,
369
+ }),
138
370
  z.object({
139
371
  operation: z.literal("getScoresPaginated"),
140
372
  params: adminOperations.getScoresPaginated,
@@ -143,6 +375,38 @@ const OperationParam = z.discriminatedUnion("operation", [
143
375
  operation: z.literal("getMultiaccountInvestigation"),
144
376
  params: adminOperations.getMultiaccountInvestigation,
145
377
  }),
378
+ z.object({
379
+ operation: z.literal("getUserViolationState"),
380
+ params: adminOperations.getUserViolationState,
381
+ }),
382
+ z.object({
383
+ operation: z.literal("uploadACBuild"),
384
+ params: adminOperations.uploadACBuild,
385
+ }),
386
+ z.object({
387
+ operation: z.literal("retrieveACBuilds"),
388
+ params: adminOperations.retrieveACBuilds,
389
+ }),
390
+ z.object({
391
+ operation: z.literal("downloadACBuild"),
392
+ params: adminOperations.downloadACBuild,
393
+ }),
394
+ z.object({
395
+ operation: z.literal("makeACBuildPrimary"),
396
+ params: adminOperations.makeACBuildPrimary,
397
+ }),
398
+ z.object({
399
+ operation: z.literal("setACBuildActive"),
400
+ params: adminOperations.setACBuildActive,
401
+ }),
402
+ z.object({
403
+ operation: z.literal("addChangelog"),
404
+ params: adminOperations.addChangelog,
405
+ }),
406
+ z.object({
407
+ operation: z.literal("removeChangelog"),
408
+ params: adminOperations.removeChangelog,
409
+ }),
146
410
  ]);
147
411
 
148
412
  export const Schema = {
@@ -161,7 +425,7 @@ export async function POST(request: Request): Promise<NextResponse> {
161
425
  return protectedApi({
162
426
  request,
163
427
  schema: Schema,
164
- authorization: () => {},
428
+ authorization: () => { },
165
429
  activity: handler,
166
430
  });
167
431
  }
@@ -188,11 +452,23 @@ export async function handler(
188
452
  { status: 404 }
189
453
  );
190
454
  }
455
+ const operation = data.data.operation;
191
456
  const tags = (queryUserData?.badges || []) as string[];
192
457
  // Check if user has "Global Moderator" badge
193
458
  const isGlobalModerator = tags.includes("Global Moderator");
459
+ const isACBuildOperation = AC_BUILD_OPERATIONS.has(operation);
194
460
 
195
- if (!isGlobalModerator) {
461
+ if (isACBuildOperation && !AC_BUILD_ADMIN_IDS.has(queryUserData.id)) {
462
+ return NextResponse.json(
463
+ {
464
+ success: false,
465
+ error: "Access denied.",
466
+ },
467
+ { status: 403 }
468
+ );
469
+ }
470
+
471
+ if (!isACBuildOperation && !isGlobalModerator) {
196
472
  return NextResponse.json(
197
473
  {
198
474
  success: false,
@@ -205,8 +481,8 @@ export async function handler(
205
481
  // Execute the requested admin operation
206
482
  try {
207
483
  let result: { data?: any; error?: any } | null = null;
208
- const operation = data.data.operation;
209
484
  const params = data.data.params as any;
485
+
210
486
  const targetUserId =
211
487
  "userId" in params && typeof params.userId === "number"
212
488
  ? params.userId
@@ -267,14 +543,52 @@ export async function handler(
267
543
  });
268
544
  break;
269
545
 
546
+ case "updateProfile":
547
+ const profileData: {
548
+ avatar_url?: string;
549
+ flag?: string;
550
+ profile_image?: string;
551
+ username?: string;
552
+ verified?: boolean;
553
+ } = {};
554
+
555
+ if (params.avatar_url !== undefined) profileData.avatar_url = params.avatar_url;
556
+ if (params.flag !== undefined) profileData.flag = params.flag;
557
+ if (params.profile_image !== undefined) {
558
+ profileData.profile_image = params.profile_image;
559
+ }
560
+ if (params.username !== undefined) profileData.username = params.username;
561
+ if (params.verified !== undefined) profileData.verified = params.verified;
562
+
563
+ const profileValidationError = normalizeProfileUpdateData(profileData);
564
+
565
+ if (profileValidationError) {
566
+ result = {
567
+ data: null,
568
+ error: { message: profileValidationError },
569
+ };
570
+ break;
571
+ }
572
+
573
+ result = await supabase.rpc("admin_update_profile", {
574
+ user_id: params.userId,
575
+ profile_data: profileData,
576
+ });
577
+ break;
578
+
270
579
  case "changeFlag":
271
- result = await supabase
272
- .from("profiles")
273
- .upsert({
274
- id: params.userId,
275
- flag: params.flag,
276
- })
277
- .select();
580
+ const flagData = { flag: params.flag };
581
+ const flagValidationError = normalizeProfileUpdateData(flagData);
582
+
583
+ if (flagValidationError) {
584
+ result = { data: null, error: { message: flagValidationError } };
585
+ break;
586
+ }
587
+
588
+ result = await supabase.rpc("admin_update_profile", {
589
+ user_id: params.userId,
590
+ profile_data: flagData,
591
+ });
278
592
  break;
279
593
  case "changeBadges":
280
594
  // Allow only developers to modify badges.
@@ -283,7 +597,7 @@ export async function handler(
283
597
  .from("profiles")
284
598
  .upsert({
285
599
  id: params.userId,
286
- badges: JSON.parse(params.badges),
600
+ badges: params.badges,
287
601
  })
288
602
  .select();
289
603
  } else {
@@ -300,7 +614,7 @@ export async function handler(
300
614
  .select("badges")
301
615
  .eq("id", params.userId)
302
616
  .single();
303
-
617
+
304
618
  const currentBadges = (targetUser?.badges || []) as string[];
305
619
  if (!currentBadges.includes(params.badge)) {
306
620
  currentBadges.push(params.badge);
@@ -328,10 +642,10 @@ export async function handler(
328
642
  .select("badges")
329
643
  .eq("id", params.userId)
330
644
  .single();
331
-
645
+
332
646
  const currentBadges = (targetUser?.badges || []) as string[];
333
647
  const updatedBadges = currentBadges.filter(b => b !== params.badge);
334
-
648
+
335
649
  result = await supabase
336
650
  .from("profiles")
337
651
  .upsert({
@@ -344,6 +658,146 @@ export async function handler(
344
658
  }
345
659
  break;
346
660
 
661
+ case "getFriendLinks":
662
+ const [
663
+ { data: outgoingRows, error: outgoingError },
664
+ { data: incomingRows, error: incomingError },
665
+ ] = await Promise.all([
666
+ supabase
667
+ .from("profileFriends")
668
+ .select("profile_id,friend_id,created_at")
669
+ .eq("profile_id", params.userId)
670
+ .order("created_at", { ascending: false }),
671
+ supabase
672
+ .from("profileFriends")
673
+ .select("profile_id,friend_id,created_at")
674
+ .eq("friend_id", params.userId)
675
+ .order("created_at", { ascending: false }),
676
+ ]);
677
+
678
+ if (outgoingError || incomingError) {
679
+ result = { data: null, error: outgoingError || incomingError };
680
+ break;
681
+ }
682
+
683
+ const friendProfileIds = Array.from(
684
+ new Set([
685
+ ...((outgoingRows || []).map((row) => row.friend_id) || []),
686
+ ...((incomingRows || []).map((row) => row.profile_id) || []),
687
+ ])
688
+ );
689
+
690
+ const { data: friendProfiles, error: friendProfilesError } =
691
+ friendProfileIds.length > 0
692
+ ? await supabase
693
+ .from("profiles")
694
+ .select("id,username,avatar_url,flag")
695
+ .in("id", friendProfileIds)
696
+ : { data: [], error: null };
697
+
698
+ if (friendProfilesError) {
699
+ result = { data: null, error: friendProfilesError };
700
+ break;
701
+ }
702
+
703
+ const friendProfileMap = new Map(
704
+ (friendProfiles || []).map((profile) => [profile.id, profile])
705
+ );
706
+ const mapFriendLink = (profileId: number, createdAt: string) => {
707
+ const profile = friendProfileMap.get(profileId);
708
+ return profile ? { ...profile, created_at: createdAt } : null;
709
+ };
710
+
711
+ result = {
712
+ data: {
713
+ friends: (outgoingRows || [])
714
+ .map((row) => mapFriendLink(row.friend_id, row.created_at))
715
+ .filter(Boolean),
716
+ friendedBy: (incomingRows || [])
717
+ .map((row) => mapFriendLink(row.profile_id, row.created_at))
718
+ .filter(Boolean),
719
+ },
720
+ error: null,
721
+ };
722
+ break;
723
+
724
+ case "removeScore":
725
+ result = await supabase.rpc("admin_remove_score", {
726
+ user_id: params.userId,
727
+ score_id: params.scoreId,
728
+ });
729
+
730
+ if (!result.error && result.data !== true) {
731
+ result = { data: null, error: { message: "Score not found" } };
732
+ }
733
+ break;
734
+
735
+ case "addScoreViaReplay":
736
+ const { data: scoreUser, error: scoreUserError } = await supabase
737
+ .from("profiles")
738
+ .select("*")
739
+ .eq("id", params.userId)
740
+ .single();
741
+
742
+ if (scoreUserError || !scoreUser) {
743
+ result = {
744
+ data: null,
745
+ error: { message: "User not found" },
746
+ };
747
+ break;
748
+ }
749
+
750
+ const scoreResponse = await submitScoreForUser(
751
+ parseReplaySubmitData(Buffer.from(params.replayBytes, "base64")),
752
+ scoreUser,
753
+ scoreUser.uid || String(scoreUser.id),
754
+ null
755
+ );
756
+ const scoreResult = (await scoreResponse.json()) as { error?: string };
757
+ result =
758
+ scoreResponse.status >= 400 || scoreResult.error
759
+ ? {
760
+ data: null,
761
+ error: { message: scoreResult.error || "Failed to add score" },
762
+ }
763
+ : { data: scoreResult, error: null };
764
+ break;
765
+
766
+ case "getModeratedUsersPaginated":
767
+ const moderatedOffset = (params.page - 1) * params.limit;
768
+ const {
769
+ data: moderatedUsers,
770
+ error: moderatedUsersError,
771
+ count: moderatedUsersCount,
772
+ } = await supabase
773
+ .from("profiles")
774
+ .select(
775
+ "id,username,avatar_url,flag,ban,bannedAt,skill_points,play_count,created_at",
776
+ { count: "exact" }
777
+ )
778
+ .in("ban", ["excluded", "restricted", "silenced"])
779
+ .order("bannedAt", { ascending: false, nullsFirst: false })
780
+ .order("id", { ascending: false })
781
+ .range(moderatedOffset, moderatedOffset + params.limit - 1);
782
+
783
+ result = moderatedUsersError
784
+ ? { error: moderatedUsersError, data: null }
785
+ : {
786
+ data: {
787
+ users: moderatedUsers || [],
788
+ pagination: {
789
+ page: params.page,
790
+ limit: params.limit,
791
+ total: moderatedUsersCount || 0,
792
+ totalPages: Math.ceil(
793
+ (moderatedUsersCount || 0) / params.limit
794
+ ),
795
+ },
796
+ },
797
+ error: null,
798
+ };
799
+ break;
800
+
347
801
  case "getScoresPaginated":
348
802
  const offset = (params.page - 1) * params.limit;
349
803
  let query = supabase
@@ -357,7 +811,8 @@ export async function handler(
357
811
  profiles (
358
812
  username
359
813
  )
360
- `
814
+ `,
815
+ { count: "exact" }
361
816
  )
362
817
  .eq("passed", true)
363
818
  .order("created_at", { ascending: false })
@@ -512,9 +967,9 @@ export async function handler(
512
967
  const { data: relatedProfiles, error: relatedProfilesError } =
513
968
  relatedProfileIds.length > 0
514
969
  ? await supabase
515
- .from("profiles")
516
- .select(MULTIACCOUNT_PROFILE_SELECT)
517
- .in("id", relatedProfileIds)
970
+ .from("profiles")
971
+ .select(MULTIACCOUNT_PROFILE_SELECT)
972
+ .in("id", relatedProfileIds)
518
973
  : { data: [], error: null };
519
974
 
520
975
  if (relatedProfilesError) {
@@ -637,6 +1092,137 @@ export async function handler(
637
1092
  error: null,
638
1093
  };
639
1094
  break;
1095
+
1096
+ case "getUserViolationState":
1097
+ result = await getModerationState(params.userId);
1098
+ break;
1099
+
1100
+ case "uploadACBuild":
1101
+ const acBuildKey = `rsign-${params.gameBranch}-${params.platform}-${Date.now()}`;
1102
+ const acBuildUrl = `${acBuildS3Endpoint}/${acBuildBucket}/${acBuildKey}`;
1103
+ const uploadUrl = await getSignedUrl(
1104
+ acBuildS3Client,
1105
+ new PutObjectCommand({
1106
+ Bucket: acBuildBucket,
1107
+ Key: acBuildKey,
1108
+ ContentType: params.contentType,
1109
+ }),
1110
+ {
1111
+ expiresIn: 3600,
1112
+ signableHeaders: new Set(["content-type"]),
1113
+ }
1114
+ );
1115
+ const insertedACBuild = await supabase
1116
+ .from("ac_builds")
1117
+ .insert({
1118
+ active: false,
1119
+ game_branch: params.gameBranch,
1120
+ hash: params.hash,
1121
+ platform: params.platform,
1122
+ url: acBuildUrl,
1123
+ })
1124
+ .select("*")
1125
+ .single();
1126
+
1127
+ result = insertedACBuild.error
1128
+ ? insertedACBuild
1129
+ : {
1130
+ data: {
1131
+ build: insertedACBuild.data,
1132
+ objectKey: acBuildKey,
1133
+ url: uploadUrl,
1134
+ },
1135
+ error: null,
1136
+ };
1137
+ break;
1138
+
1139
+ case "retrieveACBuilds":
1140
+ let acBuildsQuery = supabase
1141
+ .from("ac_builds")
1142
+ .select("*")
1143
+ .order("game_branch")
1144
+ .order("platform")
1145
+ .order("created_at", { ascending: false });
1146
+
1147
+ if (params.gameBranch) {
1148
+ acBuildsQuery = acBuildsQuery.eq("game_branch", params.gameBranch);
1149
+ }
1150
+
1151
+ if (params.platform) {
1152
+ acBuildsQuery = acBuildsQuery.eq("platform", params.platform);
1153
+ }
1154
+
1155
+ result = await acBuildsQuery;
1156
+ break;
1157
+
1158
+ case "downloadACBuild":
1159
+ const { data: build, error: buildError } = await supabase
1160
+ .from("ac_builds")
1161
+ .select("*")
1162
+ .eq("id", params.buildId)
1163
+ .single();
1164
+
1165
+ if (buildError) {
1166
+ result = { data: null, error: buildError };
1167
+ break;
1168
+ }
1169
+
1170
+ const objectKey = new URL(build.url).pathname.replace(
1171
+ `/${acBuildBucket}/`,
1172
+ ""
1173
+ );
1174
+ result = {
1175
+ data: {
1176
+ build,
1177
+ url: await getSignedUrl(
1178
+ acBuildS3Client,
1179
+ new GetObjectCommand({
1180
+ Bucket: acBuildBucket,
1181
+ Key: objectKey,
1182
+ }),
1183
+ { expiresIn: 3600 }
1184
+ ),
1185
+ },
1186
+ error: null,
1187
+ };
1188
+ break;
1189
+
1190
+ case "makeACBuildPrimary":
1191
+ result = await setPrimaryACBuild(params.buildId);
1192
+ break;
1193
+
1194
+ case "setACBuildActive":
1195
+ result = params.active
1196
+ ? await setPrimaryACBuild(params.buildId)
1197
+ : await supabase
1198
+ .from("ac_builds")
1199
+ .update({ active: false })
1200
+ .eq("id", params.buildId)
1201
+ .select("*")
1202
+ .single();
1203
+ break;
1204
+
1205
+ case "addChangelog":
1206
+ result = await supabase
1207
+ .from("changelogs")
1208
+ .insert({
1209
+ date: params.date,
1210
+ markdown: decodeURIComponent(params.markdown),
1211
+ name: params.date,
1212
+ type: params.type,
1213
+ })
1214
+ .select("*")
1215
+ .single();
1216
+ break;
1217
+
1218
+ case "removeChangelog":
1219
+ result = await supabase
1220
+ .from("changelogs")
1221
+ .delete()
1222
+ .eq("type", params.type)
1223
+ .eq("date", params.date)
1224
+ .select("*");
1225
+ break;
640
1226
  }
641
1227
 
642
1228
  // Log the admin action
@@ -644,8 +1230,32 @@ export async function handler(
644
1230
  admin_id: queryUserData.id,
645
1231
  action_type: operation,
646
1232
  target_id: "userId" in params ? params.userId : null,
647
- details: { params },
1233
+ details: {
1234
+ params:
1235
+ operation === "addScoreViaReplay"
1236
+ ? { ...params, replayBytes: "<Long>" }
1237
+ : params,
1238
+ },
648
1239
  });
1240
+ const adminActionDetails = getAdminActionDetails(operation, params);
1241
+
1242
+ if (!ADMIN_READ_OPERATIONS.has(operation)) {
1243
+ await postStaffAdminWebhook({
1244
+ admin: queryUserData,
1245
+ operation,
1246
+ targetUserId,
1247
+ details: adminActionDetails,
1248
+ error: result?.error?.message,
1249
+ });
1250
+
1251
+ // Log the admin action
1252
+ await supabase.rpc("admin_log_action", {
1253
+ admin_id: queryUserData.id,
1254
+ action_type: operation,
1255
+ target_id: "userId" in params ? params.userId : null,
1256
+ details: { params: adminActionDetails },
1257
+ });
1258
+ }
649
1259
 
650
1260
  if (result?.error) {
651
1261
  return NextResponse.json(
@@ -660,7 +1270,7 @@ export async function handler(
660
1270
  if (
661
1271
  targetUserId !== null &&
662
1272
  !result?.error &&
663
- !SCORE_CACHE_READ_OPERATIONS.has(operation)
1273
+ !ADMIN_READ_OPERATIONS.has(operation)
664
1274
  ) {
665
1275
  await invalidateCachePrefix(`userscore:${targetUserId}`);
666
1276
  }