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.
- package/api/createBeatmap.ts +64 -40
- package/api/editProfile.ts +4 -67
- package/api/executeAdminOperation.ts +637 -27
- package/api/getAvatarUploadUrl.ts +90 -85
- package/api/getBeatmapPage.ts +2 -0
- package/api/getBeatmapPageById.ts +2 -0
- package/api/getBeatmaps.ts +110 -197
- package/api/getChangelog.ts +46 -0
- package/api/getCollection.ts +44 -31
- package/api/getMapUploadUrl.ts +90 -93
- package/api/getProfile.ts +297 -297
- package/api/getScore.ts +2 -0
- package/api/getVideoUploadUrl.ts +90 -85
- package/api/submitScoreInternal.ts +506 -461
- package/api/updateBeatmapPage.ts +6 -0
- package/beatmap-file-urls.json +29398 -0
- package/handleApi.ts +7 -4
- package/index.ts +193 -162
- package/package.json +7 -3
- package/queries/admin_delete_user.sql +42 -39
- package/queries/admin_remove_all_scores.sql +6 -3
- package/queries/admin_remove_score.sql +107 -0
- package/queries/admin_update_profile.sql +22 -0
- package/queries/get_beatmaps_v2.sql +48 -0
- package/queries/get_top_scores_for_beatmap3.sql +47 -38
- package/queries/profile_update_guards.sql +66 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/linked-project.json +1 -0
- package/types/database.ts +1702 -1450
- package/utils/beatmapFiles.ts +102 -0
- package/utils/beatmapHash.ts +239 -0
- package/utils/beatmapTopScores.ts +68 -84
- package/utils/getUserBySession.ts +3 -1
- package/utils/moderation.ts +101 -0
- package/utils/profileUpdateValidation.ts +51 -0
- package/utils/redis.ts +24 -0
- package/utils/requestUtils.ts +2 -2
- package/utils/rhrReplay.ts +122 -0
- package/utils/star-calc/formatSingle.ts +107 -0
- package/utils/star-calc/rhmParser.ts +214 -0
- package/utils/star-calc/sspmParser.ts +294 -160
- package/worker.ts +197 -195
- 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
|
|
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 {
|
|
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
|
|
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({
|
|
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 (!
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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:
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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: {
|
|
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
|
-
!
|
|
1273
|
+
!ADMIN_READ_OPERATIONS.has(operation)
|
|
664
1274
|
) {
|
|
665
1275
|
await invalidateCachePrefix(`userscore:${targetUserId}`);
|
|
666
1276
|
}
|