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,38 +1,17 @@
1
1
  import { NextResponse } from "../utils/response";
2
2
  import z from "zod";
3
3
  import { protectedApi, validUser } from "../utils/requestUtils";
4
- import { SSPMParser } from "../utils/star-calc/sspmParser";
4
+ import { SSPMParser, type SSPMParsedMap } from "../utils/star-calc/sspmParser";
5
+ import {
6
+ isRHM,
7
+ parseRHM,
8
+ rhmToSSPMParsedMap,
9
+ } from "../utils/star-calc/rhmParser";
5
10
  import { supabase } from "../utils/supabase";
6
- import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
7
11
  import { rateMap } from "../utils/star-calc";
8
12
  import { getUserBySession } from "../utils/getUserBySession";
9
13
  import { User } from "@supabase/supabase-js";
10
-
11
- const s3Client = new S3Client({
12
- region: "auto",
13
- endpoint: "https://s3.eu-central-003.backblazeb2.com",
14
- credentials: {
15
- secretAccessKey: process.env.SECRET_BUCKET || "",
16
- accessKeyId: process.env.ACCESS_BUCKET || "",
17
- },
18
- requestChecksumCalculation: "WHEN_REQUIRED",
19
- });
20
-
21
- s3Client.middlewareStack.add(
22
- (next) =>
23
- async (args): Promise<any> => {
24
- const request = args.request as RequestInit;
25
- const headers = request.headers as Record<string, string>;
26
- delete headers["x-amz-checksum-crc32"];
27
- delete headers["x-amz-checksum-crc32c"];
28
- delete headers["x-amz-checksum-sha1"];
29
- delete headers["x-amz-checksum-sha256"];
30
- request.headers = headers;
31
-
32
- return next(args);
33
- },
34
- { step: "build", name: "customHeaders" }
35
- );
14
+ import { putStaticObject, readStaticObject } from "../utils/beatmapFiles";
36
15
 
37
16
  function getCoverContentType(cover: Uint8Array) {
38
17
  if (
@@ -105,11 +84,59 @@ export async function handler({
105
84
  if (!url.startsWith(`https://static.rhythia.com/`))
106
85
  return NextResponse.json({ error: "Invalid url" });
107
86
 
108
- const request = await fetch(url);
109
- const bytes = await request.arrayBuffer();
110
- const parser = new SSPMParser(Buffer.from(bytes));
87
+ let parsedData: SSPMParsedMap | undefined;
88
+ let parseError: unknown;
89
+
90
+ for (let attempt = 0; attempt < 4; attempt++) {
91
+ try {
92
+ const { object, objectKey, bytes } = await readStaticObject(url);
93
+ const isSSPM = bytes.subarray(0, 4).toString("hex") === "53532b6d";
94
+ const isBeatmapFile = isSSPM || isRHM(bytes);
95
+ console.log("createBeatmap object fetch result", {
96
+ attempt: attempt + 1,
97
+ url,
98
+ objectKey,
99
+ contentType: object.ContentType,
100
+ contentLength: object.ContentLength,
101
+ byteLength: bytes.length,
102
+ firstBytesHex: bytes.subarray(0, 16).toString("hex"),
103
+ firstBytesText: bytes
104
+ .subarray(0, 16)
105
+ .toString("utf8")
106
+ .replace(/[^\x20-\x7e]/g, "."),
107
+ bodyPreview:
108
+ !isBeatmapFile && bytes.length <= 512
109
+ ? bytes.toString("utf8").replace(/[^\x09\x0a\x0d\x20-\x7e]/g, ".")
110
+ : undefined,
111
+ eTag: object.ETag,
112
+ versionId: object.VersionId,
113
+ });
114
+
115
+ parsedData = isRHM(bytes)
116
+ ? rhmToSSPMParsedMap(parseRHM(bytes))
117
+ : new SSPMParser(bytes).parse();
118
+ console.log("createBeatmap parsed beatmap", {
119
+ attempt: attempt + 1,
120
+ mapID: parsedData.strings.mapID,
121
+ noteCount: parsedData.metadata.noteCount,
122
+ markerCount: parsedData.metadata.markerCount,
123
+ });
124
+ break;
125
+ } catch (error) {
126
+ parseError = error;
127
+ console.log("createBeatmap parse failed", {
128
+ attempt: attempt + 1,
129
+ error: error?.toString?.() || String(error),
130
+ });
131
+
132
+ if (attempt < 3) {
133
+ await new Promise((resolve) => setTimeout(resolve, 1000));
134
+ }
135
+ }
136
+ }
137
+
138
+ if (!parsedData) throw parseError;
111
139
 
112
- const parsedData = parser.parse();
113
140
  const digested = parsedData.strings.mapID;
114
141
 
115
142
  const user = (await getUserBySession(session)) as User;
@@ -169,14 +196,11 @@ export async function handler({
169
196
  const cover = parsedData.cover || Buffer.from([]);
170
197
  const coverContentType = getCoverContentType(cover);
171
198
 
172
- await s3Client.send(
173
- new PutObjectCommand({
174
- Bucket: "rhthia-avatars",
175
- Key: imgkey,
176
- Body: cover,
177
- ContentType: coverContentType,
178
- })
179
- );
199
+ await putStaticObject({
200
+ key: imgkey,
201
+ body: cover,
202
+ contentType: coverContentType,
203
+ });
180
204
 
181
205
  const markers = parsedData.markers.sort((a, b) => a.position - b.position);
182
206
 
@@ -4,15 +4,12 @@ import { Database } from "../types/database";
4
4
  import { protectedApi, validUser } from "../utils/requestUtils";
5
5
  import { supabase } from "../utils/supabase";
6
6
  import { getUserBySession } from "../utils/getUserBySession";
7
- import { COUNTRY_LIST } from "../utils/countryList";
8
7
  import { User } from "@supabase/supabase-js";
9
- import validator from "validator";
10
- import removeZeroWidth from "zero-width";
8
+ import { normalizeProfileUpdateData } from "../utils/profileUpdateValidation";
11
9
 
12
10
  const USERNAME_CHANGE_COOLDOWN_ERROR =
13
11
  "Username can only be changed once every 6 months";
14
12
  const FLAG_CHANGE_ERROR = "Flag can only be changed once";
15
- const countryCodes = new Set(COUNTRY_LIST);
16
13
 
17
14
  function getNextUsernameChangeAt(
18
15
  lastChangedAt: string | null | undefined
@@ -71,70 +68,10 @@ export async function POST(request: Request): Promise<NextResponse> {
71
68
  export async function handler(
72
69
  data: (typeof Schema)["input"]["_type"]
73
70
  ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
74
- if (data.data.username !== undefined) {
75
- data.data.username = removeZeroWidth(data.data.username);
71
+ const validationError = normalizeProfileUpdateData(data.data);
76
72
 
77
- if (validator.trim(data.data.username) !== data.data.username) {
78
- return NextResponse.json(
79
- {
80
- error: "Username can't start or end with spaces.",
81
- },
82
- { status: 404 }
83
- );
84
- }
85
-
86
- if (data.data.username.length < 2) {
87
- return NextResponse.json(
88
- {
89
- error: "Username must be at least 2 characters long",
90
- },
91
- { status: 404 }
92
- );
93
- }
94
-
95
- if (data.data.username.length > 16) {
96
- return NextResponse.json(
97
- {
98
- error: "Username must be 16 characters or fewer.",
99
- },
100
- { status: 404 }
101
- );
102
- }
103
-
104
- if (!/^[A-Za-z0-9._]+$/.test(data.data.username)) {
105
- return NextResponse.json(
106
- {
107
- error:
108
- "Username can only contain English letters (a-z), numbers (0-9), and up to one '_' or '.'",
109
- },
110
- { status: 404 }
111
- );
112
- }
113
-
114
- const specialCharacters = data.data.username.match(/[._]/g) ?? [];
115
- if (specialCharacters.length > 1) {
116
- return NextResponse.json(
117
- {
118
- error: "Username can contain at most one '_' or one '.'",
119
- },
120
- { status: 404 }
121
- );
122
- }
123
- }
124
-
125
- if (data.data.flag !== undefined) {
126
- const normalizedFlag = validator.trim(data.data.flag).toUpperCase();
127
-
128
- if (!countryCodes.has(normalizedFlag as (typeof COUNTRY_LIST)[number])) {
129
- return NextResponse.json(
130
- {
131
- error: "Flag must be a valid country code.",
132
- },
133
- { status: 404 }
134
- );
135
- }
136
-
137
- data.data.flag = normalizedFlag;
73
+ if (validationError) {
74
+ return NextResponse.json({ error: validationError }, { status: 404 });
138
75
  }
139
76
 
140
77
  const user = (await getUserBySession(data.session)) as User;