rhythia-api 233.0.0 → 234.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 (90) hide show
  1. package/.codex +0 -0
  2. package/.env +1 -12
  3. package/README.md +4 -4
  4. package/api/acceptInvite.ts +1 -1
  5. package/api/addCollectionMap.ts +1 -1
  6. package/api/chartPublicStats.ts +1 -1
  7. package/api/checkQualified.ts +93 -93
  8. package/api/createBeatmap.ts +53 -62
  9. package/api/createBeatmapPage.ts +1 -1
  10. package/api/createClan.ts +1 -1
  11. package/api/createCollection.ts +1 -1
  12. package/api/createInvite.ts +1 -1
  13. package/api/createSupporter.ts +1 -1
  14. package/api/deleteBeatmapPage.ts +2 -5
  15. package/api/deleteCollection.ts +1 -1
  16. package/api/deleteCollectionMap.ts +1 -1
  17. package/api/editAboutMe.ts +1 -1
  18. package/api/editClan.ts +1 -1
  19. package/api/editCollection.ts +1 -2
  20. package/api/editProfile.ts +1 -1
  21. package/api/enhancedSearch.ts +113 -113
  22. package/api/executeAdminOperation.ts +1 -22
  23. package/api/getAvatarUploadUrl.ts +1 -1
  24. package/api/getBadgeLeaders.ts +1 -1
  25. package/api/getBadgedUsers.ts +1 -1
  26. package/api/getBeatmapComments.ts +1 -1
  27. package/api/getBeatmapPage.ts +74 -106
  28. package/api/getBeatmapPageById.ts +70 -109
  29. package/api/getBeatmapStarRating.ts +1 -1
  30. package/api/getBeatmaps.ts +1 -1
  31. package/api/getClan.ts +1 -1
  32. package/api/getClans.ts +1 -1
  33. package/api/getCollection.ts +1 -1
  34. package/api/getCollections.ts +1 -1
  35. package/api/getInventory.ts +1 -1
  36. package/api/getLeaderboard.ts +1 -1
  37. package/api/getMapUploadUrl.ts +2 -2
  38. package/api/getOnlinePlayers.ts +1 -1
  39. package/api/getPassToken.ts +1 -1
  40. package/api/getProfile.ts +51 -31
  41. package/api/getPublicStats.ts +5 -5
  42. package/api/getRawStarRating.ts +1 -1
  43. package/api/getScore.ts +1 -1
  44. package/api/getStoryBeatmaps.ts +1 -1
  45. package/api/getTimestamp.ts +1 -1
  46. package/api/getUserScores.ts +19 -19
  47. package/api/getVerified.ts +1 -1
  48. package/api/getVideoUploadUrl.ts +1 -1
  49. package/api/postBeatmapComment.ts +1 -1
  50. package/api/qualifyMap.ts +97 -92
  51. package/api/rankMapsArchive.ts +20 -20
  52. package/api/searchUsers.ts +1 -1
  53. package/api/setPasskey.ts +1 -1
  54. package/api/submitScore.ts +1 -6
  55. package/api/submitScoreInternal.ts +461 -449
  56. package/api/updateBeatmapPage.ts +1 -1
  57. package/api/vetoMap.ts +101 -101
  58. package/index.ts +165 -153
  59. package/package.json +7 -12
  60. package/queries/admin_delete_user.sql +39 -39
  61. package/queries/admin_exclude_user.sql +21 -21
  62. package/queries/admin_invalidate_ranked_scores.sql +18 -18
  63. package/queries/admin_log_action.sql +10 -10
  64. package/queries/admin_profanity_clear.sql +29 -29
  65. package/queries/admin_remove_all_scores.sql +29 -29
  66. package/queries/admin_restrict_user.sql +21 -21
  67. package/queries/admin_search_users.sql +24 -24
  68. package/queries/admin_silence_user.sql +21 -21
  69. package/queries/admin_unban_user.sql +21 -21
  70. package/queries/enhanced_search.sql +217 -217
  71. package/queries/get_badge_leaderboard.sql +50 -50
  72. package/queries/get_clan_leaderboard.sql +68 -68
  73. package/queries/get_collections_v4.sql +109 -109
  74. package/queries/get_top_scores_for_beatmap.sql +44 -44
  75. package/queries/get_top_scores_for_beatmap3.sql +38 -0
  76. package/queries/get_user_by_email.sql +32 -32
  77. package/queries/get_user_scores_lastday.sql +47 -47
  78. package/queries/get_user_scores_reign.sql +31 -31
  79. package/queries/get_user_scores_top_and_stats.sql +84 -84
  80. package/queries/grant_special_badges.sql +69 -69
  81. package/types/database.ts +1288 -1248
  82. package/utils/beatmapTopScores.ts +84 -0
  83. package/utils/mapLifecycleWebhook.ts +287 -277
  84. package/utils/requestGeo.ts +13 -0
  85. package/utils/requestUtils.ts +127 -127
  86. package/utils/response.ts +11 -0
  87. package/worker.ts +189 -0
  88. package/wrangler.jsonc +10 -0
  89. package/index.html +0 -3
  90. package/vercel.json +0 -13
@@ -1,449 +1,461 @@
1
- import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi, validUser } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
5
- import { decryptString } from "../utils/security";
6
- import { isEqual } from "lodash";
7
- import { getUserBySession } from "../utils/getUserBySession";
8
- import { User } from "@supabase/supabase-js";
9
- import { invalidateCachePrefix } from "../utils/cache";
10
- import { createClient, RedisClientType } from "redis";
11
- import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
12
-
13
- const s3Client = new S3Client({
14
- region: "auto",
15
- endpoint: "https://s3.eu-central-003.backblazeb2.com",
16
- credentials: {
17
- secretAccessKey: process.env.SECRET_BUCKET || "",
18
- accessKeyId: process.env.ACCESS_BUCKET || "",
19
- },
20
- requestChecksumCalculation: "WHEN_REQUIRED",
21
- });
22
-
23
- s3Client.middlewareStack.add(
24
- (next) =>
25
- async (args): Promise<any> => {
26
- const request = args.request as RequestInit;
27
- const headers = (request.headers || {}) as Record<string, string>;
28
-
29
- delete headers["x-amz-checksum-crc32"];
30
- delete headers["x-amz-checksum-crc32c"];
31
- delete headers["x-amz-checksum-sha1"];
32
- delete headers["x-amz-checksum-sha256"];
33
-
34
- request.headers = headers;
35
- return next(args);
36
- },
37
- { step: "build", name: "stripBackblazeChecksumHeaders" }
38
- );
39
-
40
- export const Schema = {
41
- input: z.strictObject({
42
- session: z.string(),
43
- secret: z.string(),
44
- token: z.string(),
45
- data: z.strictObject({
46
- onlineMapId: z.number(),
47
- misses: z.number(),
48
- hits: z.number(),
49
- speed: z.number(),
50
- mods: z.array(z.string()),
51
- spin: z.boolean(),
52
- replayBytes: z.string().nullable().optional(),
53
- pauses: z.number().nullable().optional(),
54
- failTime: z.number().nullable().optional(),
55
- }),
56
- }),
57
- output: z.object({
58
- error: z.string().optional(),
59
- }),
60
- };
61
-
62
- function easeInExpoDeqHard(x: number, star: number) {
63
- let exponent = 100 - 12 * star;
64
- if (exponent < 5) exponent = 5;
65
- return x === 0 ? 0 : Math.pow(2, exponent * x - exponent);
66
- }
67
-
68
- export function calculatePerformancePoints(
69
- starRating: number,
70
- accuracy: number
71
- ) {
72
- return (
73
- Math.round(
74
- Math.pow(
75
- (starRating * easeInExpoDeqHard(accuracy, starRating) * 100) / 2,
76
- 2
77
- ) / 1000
78
- ) * 2
79
- );
80
- }
81
-
82
- export async function POST(request: Request): Promise<NextResponse> {
83
- console.log("Received submitScoreInternal request");
84
- return protectedApi({
85
- request,
86
- schema: Schema,
87
- authorization: validUser,
88
- activity: handler,
89
- });
90
- }
91
-
92
- export async function handler({
93
- data,
94
- token,
95
- session,
96
- secret,
97
- }: (typeof Schema)["input"]["_type"]): Promise<
98
- NextResponse<(typeof Schema)["output"]["_type"]>
99
- > {
100
- if (secret !== "testing-1") {
101
- console.log("Internal usage only");
102
- return NextResponse.json({
103
- error: "Internal usage only",
104
- });
105
- }
106
- const user = (await getUserBySession(session)) as User;
107
-
108
- if (!user) {
109
- console.log("token-expired");
110
- return NextResponse.json(
111
- {
112
- error: "token-expired",
113
- },
114
- { status: 401 }
115
- );
116
- }
117
-
118
- let { data: leversData } = await supabase
119
- .from("levers")
120
- .select("*")
121
- .eq("id", 1)
122
- .single();
123
-
124
- if (leversData && leversData.disable_scores) {
125
- console.log("Lever pulled - blocked score");
126
- return NextResponse.json({
127
- error: "Scores are temporarily disabled",
128
- });
129
- }
130
-
131
- if (data.failTime) {
132
- console.log("Fail Score don't save");
133
- return NextResponse.json({
134
- error: "Fail time scores aren't saved for now",
135
- });
136
- }
137
-
138
- let { data: userData, error: userError } = await supabase
139
- .from("profiles")
140
- .select("*")
141
- .eq("uid", user.id)
142
- .single();
143
-
144
- if (!userData) {
145
- console.log("User doesn't exist");
146
- return NextResponse.json(
147
- {
148
- error: "User doesn't exist",
149
- },
150
- { status: 400 }
151
- );
152
- }
153
-
154
- console.log(userData);
155
-
156
- if (userData.ban == "excluded" || userData.ban == "restricted") {
157
- console.log(
158
- "Silenced, restricted or excluded players can't submit scores."
159
- );
160
- return NextResponse.json(
161
- {
162
- error: "Silenced, restricted or excluded players can't submit scores.",
163
- },
164
- { status: 400 }
165
- );
166
- }
167
-
168
- let { data: beatmapPages, error: bpError } = await supabase
169
- .from("beatmapPages")
170
- .select("*")
171
- .eq("id", data.onlineMapId)
172
- .single();
173
-
174
- let { data: beatmaps, error: bpErr } = await supabase
175
- .from("beatmaps")
176
- .select("*")
177
- .eq("beatmapHash", beatmapPages!.latestBeatmapHash!)
178
- .single();
179
-
180
- if (!beatmaps) {
181
- console.log("Map not submitted");
182
- return NextResponse.json(
183
- {
184
- error: "Map not submitted",
185
- },
186
- { status: 400 }
187
- );
188
- }
189
-
190
- if (!beatmapPages) {
191
- console.log("Map page not submitted");
192
- return NextResponse.json(
193
- {
194
- error: "Map not submitted",
195
- },
196
- { status: 400 }
197
- );
198
- }
199
-
200
- await supabase.from("beatmaps").upsert({
201
- beatmapHash: beatmapPages.latestBeatmapHash!,
202
- playcount: (beatmaps!.playcount || 1) + 1,
203
- });
204
-
205
- let passed = true;
206
-
207
- // Pass invalidation
208
- if (data.misses + data.hits !== beatmaps.noteCount) {
209
- passed = false;
210
- }
211
-
212
- const accurracy = data.hits / beatmaps.noteCount!;
213
- let awarded_sp = 0;
214
-
215
- console.log(
216
- data.misses + data.hits == beatmaps.noteCount,
217
- data.misses + data.hits,
218
- beatmaps.noteCount
219
- );
220
-
221
- let multiplierMod = 1;
222
- if (data.mods.includes("mod_hardrock")) {
223
- multiplierMod *= 1; //todo: change here
224
- }
225
-
226
- if (data.mods.includes("mod_nofail")) {
227
- multiplierMod *= Math.pow(0.95, data.misses);
228
- }
229
-
230
- awarded_sp = calculatePerformancePoints(
231
- data.speed * beatmaps.starRating! * multiplierMod,
232
- accurracy
233
- );
234
-
235
- if (beatmapPages.status == "UNRANKED") {
236
- awarded_sp = 0;
237
- }
238
-
239
- console.log("p1");
240
-
241
- let client: RedisClientType | undefined;
242
- let tokenId = -1;
243
-
244
- try {
245
- if (Buffer.from(token, "base64").length != 4096) {
246
- console.log("token length check failed");
247
- return NextResponse.json(
248
- {
249
- error: "",
250
- },
251
- { status: 400 }
252
- );
253
- }
254
-
255
- client = createClient({
256
- username: process.env.REDIS_USERNAME,
257
- password: process.env.REDIS_PASSWORD,
258
- socket: {
259
- host: process.env.REDIS_HOST,
260
- port: process.env.REDIS_PORT as unknown as number,
261
- },
262
- });
263
-
264
- client.on("error", (err) => console.log("Redis Client Error", err));
265
- await client.connect();
266
-
267
- const { data, error } = await supabase
268
- .from("tokens")
269
- .insert({})
270
- .select()
271
- .single();
272
-
273
- if (error !== null) {
274
- console.log(error);
275
- throw error;
276
- }
277
-
278
- tokenId = data.id;
279
-
280
- await client.xAdd("score:tokens", "*", {
281
- token: JSON.stringify({
282
- userId: userData.id,
283
- tokenId: tokenId,
284
- token: token,
285
- }),
286
- });
287
- } catch (exception) {
288
- console.log(exception);
289
- console.log("failed to send redis request", {
290
- token: "<Long>",
291
- userId: userData.id,
292
- });
293
- } finally {
294
- client?.destroy();
295
- }
296
-
297
- // auto-exclude: if a newly-created account (>600 RP) submits a score
298
- try {
299
- const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
300
- if (
301
- awarded_sp > 600 &&
302
- userData?.created_at &&
303
- Date.now() - userData.created_at < ONE_WEEK
304
- ) {
305
- await supabase
306
- .from("profiles")
307
- .upsert({ id: userData.id, ban: "excluded", bannedAt: Date.now() });
308
-
309
- return NextResponse.json(
310
- { error: "User excluded due to suspicious activity." },
311
- { status: 400 }
312
- );
313
- }
314
- } catch (e) {
315
- console.error("safen/ auto-exclude check failed:", e);
316
- }
317
- let replayUrl: string | null = null;
318
-
319
- if (data.replayBytes) {
320
- try {
321
- const replayBuffer = Buffer.from(data.replayBytes, "base64");
322
- if (replayBuffer.length > 0) {
323
- const replayKey = `score-replay-${Date.now()}-${user.id}.rhr`;
324
- await s3Client.send(
325
- new PutObjectCommand({
326
- Bucket: "rhthia-avatars",
327
- Key: replayKey,
328
- Body: replayBuffer,
329
- ContentType: "application/octet-stream",
330
- })
331
- );
332
- replayUrl = `https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/${replayKey}`;
333
- }
334
- } catch (error) {
335
- console.error("Replay upload failed:", error);
336
- }
337
- }
338
-
339
- await supabase.from("scores").upsert({
340
- beatmapHash: beatmaps.beatmapHash,
341
- replayHwid: "",
342
- replay_url: replayUrl,
343
- songId: beatmaps.beatmapHash,
344
- userId: userData.id,
345
- passed,
346
- misses: data.misses,
347
- awarded_sp: Math.round(awarded_sp * 100) / 100,
348
- speed: data.speed,
349
- mods: data.mods,
350
- additional_data: "",
351
- spin: data.spin || false,
352
- token: tokenId === -1 ? undefined : tokenId,
353
- });
354
-
355
- console.log("p2");
356
-
357
- let { data: scores2, error: errorsp } = await supabase
358
- .from("scores")
359
- .select(`awarded_sp,beatmapHash,spin`)
360
- .eq("userId", userData.id)
361
- .neq("awarded_sp", 0)
362
- .eq("passed", true)
363
- .order("awarded_sp", { ascending: false });
364
-
365
- if (scores2 == null) return NextResponse.json({ error: "No scores" });
366
-
367
- let allHashMap: Record<string, number> = {};
368
- let spinHashMap: Record<string, number> = {};
369
-
370
- for (const score of scores2) {
371
- const { beatmapHash, awarded_sp } = score;
372
-
373
- if (!beatmapHash || !awarded_sp) continue;
374
-
375
- // Normal Scores
376
- if (!allHashMap[beatmapHash] || allHashMap[beatmapHash] < awarded_sp) {
377
- allHashMap[beatmapHash] = awarded_sp;
378
- }
379
-
380
- // Spin Scores
381
- if (score.spin) {
382
- if (!spinHashMap[beatmapHash] || spinHashMap[beatmapHash] < awarded_sp) {
383
- spinHashMap[beatmapHash] = awarded_sp;
384
- }
385
- }
386
- }
387
- // All scores
388
- const totalSp = weightCalculate(allHashMap);
389
-
390
- // Only spin scores
391
- const spinTotalSp = weightCalculate(spinHashMap);
392
-
393
- await supabase.from("profiles").upsert({
394
- id: userData.id,
395
- play_count: (userData.play_count || 0) + 1,
396
- skill_points: Math.round(totalSp * 100) / 100,
397
- spin_skill_points: Math.round(spinTotalSp * 100) / 100,
398
- squares_hit: (userData.squares_hit || 0) + data.hits,
399
- });
400
- console.log("p3");
401
-
402
- await invalidateCachePrefix(`userscore:${userData.id}`);
403
- const beatmapIsRanked =
404
- beatmapPages?.status === "RANKED" || beatmapPages?.status === "APPROVED";
405
- if (beatmapIsRanked) {
406
- await invalidateCachePrefix(`beatmap-scores:${beatmaps.beatmapHash}`);
407
- }
408
-
409
- // Grant special badges if applicable
410
- if (passed && beatmapPages && !data.mods.includes("mod_nofail")) {
411
- try {
412
- const { data: badgeResult, error: badgeError } = await supabase.rpc(
413
- "grant_special_badges",
414
- {
415
- p_user_id: userData.id,
416
- p_beatmap_id: beatmapPages.id,
417
- p_spin: data.spin || false,
418
- p_passed: passed,
419
- }
420
- );
421
-
422
- const result = badgeResult as { granted: boolean; badge: string | null };
423
- if (result && result.granted) {
424
- console.log(`Badge granted: ${result.badge} to user ${userData.id}`);
425
- }
426
- } catch (error) {
427
- console.error("Error granting badge:", error);
428
- }
429
- }
430
- return NextResponse.json({});
431
- }
432
-
433
- export function weightCalculate(hashMap: Record<string, number>) {
434
- let totalSp = 0;
435
- let weight = 100;
436
-
437
- const values = Object.values(hashMap);
438
- values.sort((a, b) => b - a);
439
-
440
- for (const score of values) {
441
- totalSp += ((score || 0) * weight) / 100;
442
- weight = weight * 0.97;
443
-
444
- if (weight < 5) {
445
- break;
446
- }
447
- }
448
- return totalSp;
449
- }
1
+ import { NextResponse } from "../utils/response";
2
+ import z from "zod";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+ import { decryptString } from "../utils/security";
6
+ import { isEqual } from "lodash";
7
+ import { getUserBySession } from "../utils/getUserBySession";
8
+ import { User } from "@supabase/supabase-js";
9
+ import { invalidateCachePrefix } from "../utils/cache";
10
+ import { createClient, RedisClientType } from "redis";
11
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
12
+
13
+ const SCORE_TOKEN_STREAM_MAXLEN = 10000;
14
+
15
+ const s3Client = new S3Client({
16
+ region: "auto",
17
+ endpoint: "https://s3.eu-central-003.backblazeb2.com",
18
+ credentials: {
19
+ secretAccessKey: process.env.SECRET_BUCKET || "",
20
+ accessKeyId: process.env.ACCESS_BUCKET || "",
21
+ },
22
+ requestChecksumCalculation: "WHEN_REQUIRED",
23
+ });
24
+
25
+ s3Client.middlewareStack.add(
26
+ (next) =>
27
+ async (args): Promise<any> => {
28
+ const request = args.request as RequestInit;
29
+ const headers = (request.headers || {}) as Record<string, string>;
30
+
31
+ delete headers["x-amz-checksum-crc32"];
32
+ delete headers["x-amz-checksum-crc32c"];
33
+ delete headers["x-amz-checksum-sha1"];
34
+ delete headers["x-amz-checksum-sha256"];
35
+
36
+ request.headers = headers;
37
+ return next(args);
38
+ },
39
+ { step: "build", name: "stripBackblazeChecksumHeaders" }
40
+ );
41
+
42
+ export const Schema = {
43
+ input: z.strictObject({
44
+ session: z.string(),
45
+ secret: z.string(),
46
+ token: z.string(),
47
+ data: z.strictObject({
48
+ onlineMapId: z.number(),
49
+ misses: z.number(),
50
+ hits: z.number(),
51
+ speed: z.number(),
52
+ mods: z.array(z.string()),
53
+ spin: z.boolean(),
54
+ replayBytes: z.string().nullable().optional(),
55
+ pauses: z.number().nullable().optional(),
56
+ failTime: z.number().nullable().optional(),
57
+ }),
58
+ }),
59
+ output: z.object({
60
+ error: z.string().optional(),
61
+ }),
62
+ };
63
+
64
+ function easeInExpoDeqHard(x: number, star: number) {
65
+ let exponent = 100 - 12 * star;
66
+ if (exponent < 5) exponent = 5;
67
+ return x === 0 ? 0 : Math.pow(2, exponent * x - exponent);
68
+ }
69
+
70
+ export function calculatePerformancePoints(
71
+ starRating: number,
72
+ accuracy: number
73
+ ) {
74
+ return (
75
+ Math.round(
76
+ Math.pow(
77
+ (starRating * easeInExpoDeqHard(accuracy, starRating) * 100) / 2,
78
+ 2
79
+ ) / 1000
80
+ ) * 2
81
+ );
82
+ }
83
+
84
+ export async function POST(request: Request): Promise<NextResponse> {
85
+ console.log("Received submitScoreInternal request");
86
+ return protectedApi({
87
+ request,
88
+ schema: Schema,
89
+ authorization: validUser,
90
+ activity: handler,
91
+ });
92
+ }
93
+
94
+ export async function handler({
95
+ data,
96
+ token,
97
+ session,
98
+ secret,
99
+ }: (typeof Schema)["input"]["_type"]): Promise<
100
+ NextResponse<(typeof Schema)["output"]["_type"]>
101
+ > {
102
+ if (secret !== "testing-1") {
103
+ console.log("Internal usage only");
104
+ return NextResponse.json({
105
+ error: "Internal usage only",
106
+ });
107
+ }
108
+ const user = (await getUserBySession(session)) as User;
109
+
110
+ if (!user) {
111
+ console.log("token-expired");
112
+ return NextResponse.json(
113
+ {
114
+ error: "token-expired",
115
+ },
116
+ { status: 401 }
117
+ );
118
+ }
119
+
120
+ let { data: leversData } = await supabase
121
+ .from("levers")
122
+ .select("*")
123
+ .eq("id", 1)
124
+ .single();
125
+
126
+ if (leversData && leversData.disable_scores) {
127
+ console.log("Lever pulled - blocked score");
128
+ return NextResponse.json({
129
+ error: "Scores are temporarily disabled",
130
+ });
131
+ }
132
+
133
+ if (data.failTime) {
134
+ console.log("Fail Score don't save");
135
+ return NextResponse.json({
136
+ error: "Fail time scores aren't saved for now",
137
+ });
138
+ }
139
+
140
+ let { data: userData, error: userError } = await supabase
141
+ .from("profiles")
142
+ .select("*")
143
+ .eq("uid", user.id)
144
+ .single();
145
+
146
+ if (!userData) {
147
+ console.log("User doesn't exist");
148
+ return NextResponse.json(
149
+ {
150
+ error: "User doesn't exist",
151
+ },
152
+ { status: 400 }
153
+ );
154
+ }
155
+
156
+ console.log(userData);
157
+
158
+ if (userData.ban == "excluded" || userData.ban == "restricted") {
159
+ console.log(
160
+ "Silenced, restricted or excluded players can't submit scores."
161
+ );
162
+ return NextResponse.json(
163
+ {
164
+ error: "Silenced, restricted or excluded players can't submit scores.",
165
+ },
166
+ { status: 400 }
167
+ );
168
+ }
169
+
170
+ let { data: beatmapPages, error: bpError } = await supabase
171
+ .from("beatmapPages")
172
+ .select("*")
173
+ .eq("id", data.onlineMapId)
174
+ .single();
175
+
176
+ let { data: beatmaps, error: bpErr } = await supabase
177
+ .from("beatmaps")
178
+ .select("*")
179
+ .eq("beatmapHash", beatmapPages!.latestBeatmapHash!)
180
+ .single();
181
+
182
+ if (!beatmaps) {
183
+ console.log("Map not submitted");
184
+ return NextResponse.json(
185
+ {
186
+ error: "Map not submitted",
187
+ },
188
+ { status: 400 }
189
+ );
190
+ }
191
+
192
+ if (!beatmapPages) {
193
+ console.log("Map page not submitted");
194
+ return NextResponse.json(
195
+ {
196
+ error: "Map not submitted",
197
+ },
198
+ { status: 400 }
199
+ );
200
+ }
201
+
202
+ await supabase.from("beatmaps").upsert({
203
+ beatmapHash: beatmapPages.latestBeatmapHash!,
204
+ playcount: (beatmaps!.playcount || 1) + 1,
205
+ });
206
+
207
+ let passed = true;
208
+
209
+ // Pass invalidation
210
+ if (data.misses + data.hits !== beatmaps.noteCount) {
211
+ passed = false;
212
+ }
213
+
214
+ const accurracy = data.hits / beatmaps.noteCount!;
215
+ let awarded_sp = 0;
216
+
217
+ console.log(
218
+ data.misses + data.hits == beatmaps.noteCount,
219
+ data.misses + data.hits,
220
+ beatmaps.noteCount
221
+ );
222
+
223
+ let multiplierMod = 1;
224
+ if (data.mods.includes("mod_hardrock")) {
225
+ multiplierMod *= 1; //todo: change here
226
+ }
227
+
228
+ if (data.mods.includes("mod_nofail")) {
229
+ multiplierMod *= Math.pow(0.95, data.misses);
230
+ }
231
+
232
+ awarded_sp = calculatePerformancePoints(
233
+ data.speed * beatmaps.starRating! * multiplierMod,
234
+ accurracy
235
+ );
236
+
237
+ if (beatmapPages.status == "UNRANKED") {
238
+ awarded_sp = 0;
239
+ }
240
+
241
+ console.log("p1");
242
+
243
+ let client: RedisClientType | undefined;
244
+ let tokenId = -1;
245
+
246
+ try {
247
+ if (Buffer.from(token, "base64").length != 4096) {
248
+ console.log("token length check failed");
249
+ return NextResponse.json(
250
+ {
251
+ error: "",
252
+ },
253
+ { status: 400 }
254
+ );
255
+ }
256
+
257
+ client = createClient({
258
+ username: process.env.REDIS_USERNAME,
259
+ password: process.env.REDIS_PASSWORD,
260
+ socket: {
261
+ host: process.env.REDIS_HOST,
262
+ port: process.env.REDIS_PORT as unknown as number,
263
+ },
264
+ });
265
+
266
+ client.on("error", (err) => console.log("Redis Client Error", err));
267
+ await client.connect();
268
+
269
+ const { data, error } = await supabase
270
+ .from("tokens")
271
+ .insert({})
272
+ .select()
273
+ .single();
274
+
275
+ if (error !== null) {
276
+ console.log(error);
277
+ throw error;
278
+ }
279
+
280
+ tokenId = data.id;
281
+
282
+ await client.xTrim("score:tokens", "MAXLEN", SCORE_TOKEN_STREAM_MAXLEN, {
283
+ strategyModifier: "~",
284
+ });
285
+
286
+ await client.xAdd(
287
+ "score:tokens",
288
+ "*",
289
+ {
290
+ token: JSON.stringify({
291
+ userId: userData.id,
292
+ tokenId: tokenId,
293
+ token: token,
294
+ }),
295
+ },
296
+ {
297
+ TRIM: {
298
+ strategy: "MAXLEN",
299
+ strategyModifier: "~",
300
+ threshold: SCORE_TOKEN_STREAM_MAXLEN,
301
+ },
302
+ }
303
+ );
304
+ } catch (exception) {
305
+ console.log(exception);
306
+ console.log("failed to send redis request", {
307
+ token: "<Long>",
308
+ userId: userData.id,
309
+ });
310
+ } finally {
311
+ client?.destroy();
312
+ }
313
+
314
+ // auto-exclude: if a newly-created account (>600 RP) submits a score
315
+ try {
316
+ const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
317
+ if (
318
+ awarded_sp > 600 &&
319
+ userData?.created_at &&
320
+ Date.now() - userData.created_at < ONE_WEEK
321
+ ) {
322
+ await supabase
323
+ .from("profiles")
324
+ .upsert({ id: userData.id, ban: "excluded", bannedAt: Date.now() });
325
+
326
+ return NextResponse.json(
327
+ { error: "User excluded due to suspicious activity." },
328
+ { status: 400 }
329
+ );
330
+ }
331
+ } catch (e) {
332
+ console.error("safen/ auto-exclude check failed:", e);
333
+ }
334
+ let replayUrl: string | null = null;
335
+
336
+ if (data.replayBytes) {
337
+ try {
338
+ const replayBuffer = Buffer.from(data.replayBytes, "base64");
339
+ if (replayBuffer.length > 0) {
340
+ const replayKey = `score-replay-${Date.now()}-${user.id}.rhr`;
341
+ await s3Client.send(
342
+ new PutObjectCommand({
343
+ Bucket: "rhthia-avatars",
344
+ Key: replayKey,
345
+ Body: replayBuffer,
346
+ ContentType: "application/octet-stream",
347
+ })
348
+ );
349
+ replayUrl = `https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/${replayKey}`;
350
+ }
351
+ } catch (error) {
352
+ console.error("Replay upload failed:", error);
353
+ }
354
+ }
355
+
356
+ await supabase.from("scores").upsert({
357
+ beatmapHash: beatmaps.beatmapHash,
358
+ replayHwid: "",
359
+ replay_url: replayUrl,
360
+ songId: beatmaps.beatmapHash,
361
+ userId: userData.id,
362
+ passed,
363
+ misses: data.misses,
364
+ awarded_sp: Math.round(awarded_sp * 100) / 100,
365
+ speed: data.speed,
366
+ mods: data.mods,
367
+ additional_data: "",
368
+ spin: data.spin || false,
369
+ token: tokenId === -1 ? undefined : tokenId,
370
+ });
371
+
372
+ console.log("p2");
373
+
374
+ let { data: scores2, error: errorsp } = await supabase
375
+ .from("scores")
376
+ .select(`awarded_sp,beatmapHash,spin`)
377
+ .eq("userId", userData.id)
378
+ .neq("awarded_sp", 0)
379
+ .eq("passed", true)
380
+ .order("awarded_sp", { ascending: false });
381
+
382
+ if (scores2 == null) return NextResponse.json({ error: "No scores" });
383
+
384
+ let allHashMap: Record<string, number> = {};
385
+ let spinHashMap: Record<string, number> = {};
386
+
387
+ for (const score of scores2) {
388
+ const { beatmapHash, awarded_sp } = score;
389
+
390
+ if (!beatmapHash || !awarded_sp) continue;
391
+
392
+ // Normal Scores
393
+ if (!allHashMap[beatmapHash] || allHashMap[beatmapHash] < awarded_sp) {
394
+ allHashMap[beatmapHash] = awarded_sp;
395
+ }
396
+
397
+ // Spin Scores
398
+ if (score.spin) {
399
+ if (!spinHashMap[beatmapHash] || spinHashMap[beatmapHash] < awarded_sp) {
400
+ spinHashMap[beatmapHash] = awarded_sp;
401
+ }
402
+ }
403
+ }
404
+ // All scores
405
+ const totalSp = weightCalculate(allHashMap);
406
+
407
+ // Only spin scores
408
+ const spinTotalSp = weightCalculate(spinHashMap);
409
+
410
+ await supabase.from("profiles").upsert({
411
+ id: userData.id,
412
+ play_count: (userData.play_count || 0) + 1,
413
+ skill_points: Math.round(totalSp * 100) / 100,
414
+ spin_skill_points: Math.round(spinTotalSp * 100) / 100,
415
+ squares_hit: (userData.squares_hit || 0) + data.hits,
416
+ });
417
+ console.log("p3");
418
+
419
+ await invalidateCachePrefix(`userscore:${userData.id}`);
420
+
421
+ // Grant special badges if applicable
422
+ if (passed && beatmapPages && !data.mods.includes("mod_nofail")) {
423
+ try {
424
+ const { data: badgeResult, error: badgeError } = await supabase.rpc(
425
+ "grant_special_badges",
426
+ {
427
+ p_user_id: userData.id,
428
+ p_beatmap_id: beatmapPages.id,
429
+ p_spin: data.spin || false,
430
+ p_passed: passed,
431
+ }
432
+ );
433
+
434
+ const result = badgeResult as { granted: boolean; badge: string | null };
435
+ if (result && result.granted) {
436
+ console.log(`Badge granted: ${result.badge} to user ${userData.id}`);
437
+ }
438
+ } catch (error) {
439
+ console.error("Error granting badge:", error);
440
+ }
441
+ }
442
+ return NextResponse.json({});
443
+ }
444
+
445
+ export function weightCalculate(hashMap: Record<string, number>) {
446
+ let totalSp = 0;
447
+ let weight = 100;
448
+
449
+ const values = Object.values(hashMap);
450
+ values.sort((a, b) => b - a);
451
+
452
+ for (const score of values) {
453
+ totalSp += ((score || 0) * weight) / 100;
454
+ weight = weight * 0.97;
455
+
456
+ if (weight < 5) {
457
+ break;
458
+ }
459
+ }
460
+ return totalSp;
461
+ }