rhythia-api 242.0.0 → 243.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 +57 -39
- package/api/editProfile.ts +4 -67
- package/api/executeAdminOperation.ts +1030 -681
- 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/getCollection.ts +44 -31
- package/api/getMapUploadUrl.ts +90 -93
- 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 +24 -21
- package/index.ts +121 -112
- package/package.json +4 -2
- 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/types/database.ts +1525 -1450
- package/utils/beatmapFiles.ts +102 -0
- package/utils/beatmapHash.ts +336 -0
- package/utils/beatmapTopScores.ts +68 -84
- package/utils/getUserBySession.ts +3 -1
- package/utils/profileUpdateValidation.ts +51 -0
- package/utils/redis.ts +24 -0
- package/utils/rhrReplay.ts +122 -0
- package/utils/star-calc/sspmParser.ts +294 -160
package/api/createBeatmap.ts
CHANGED
|
@@ -3,36 +3,10 @@ import z from "zod";
|
|
|
3
3
|
import { protectedApi, validUser } from "../utils/requestUtils";
|
|
4
4
|
import { SSPMParser } from "../utils/star-calc/sspmParser";
|
|
5
5
|
import { supabase } from "../utils/supabase";
|
|
6
|
-
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
7
6
|
import { rateMap } from "../utils/star-calc";
|
|
8
7
|
import { getUserBySession } from "../utils/getUserBySession";
|
|
9
8
|
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
|
-
);
|
|
9
|
+
import { putStaticObject, readStaticObject } from "../utils/beatmapFiles";
|
|
36
10
|
|
|
37
11
|
function getCoverContentType(cover: Uint8Array) {
|
|
38
12
|
if (
|
|
@@ -105,11 +79,58 @@ export async function handler({
|
|
|
105
79
|
if (!url.startsWith(`https://static.rhythia.com/`))
|
|
106
80
|
return NextResponse.json({ error: "Invalid url" });
|
|
107
81
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
82
|
+
let parsedData: ReturnType<SSPMParser["parse"]> | undefined;
|
|
83
|
+
let parseError: unknown;
|
|
84
|
+
|
|
85
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
const { object, objectKey, bytes } = await readStaticObject(url);
|
|
88
|
+
const isSSPM = bytes.subarray(0, 4).toString("hex") === "53532b6d";
|
|
89
|
+
console.log("createBeatmap object fetch result", {
|
|
90
|
+
attempt: attempt + 1,
|
|
91
|
+
url,
|
|
92
|
+
objectKey,
|
|
93
|
+
contentType: object.ContentType,
|
|
94
|
+
contentLength: object.ContentLength,
|
|
95
|
+
byteLength: bytes.length,
|
|
96
|
+
firstBytesHex: bytes.subarray(0, 16).toString("hex"),
|
|
97
|
+
firstBytesText: bytes
|
|
98
|
+
.subarray(0, 16)
|
|
99
|
+
.toString("utf8")
|
|
100
|
+
.replace(/[^\x20-\x7e]/g, "."),
|
|
101
|
+
bodyPreview:
|
|
102
|
+
!isSSPM && bytes.length <= 512
|
|
103
|
+
? bytes.toString("utf8").replace(/[^\x09\x0a\x0d\x20-\x7e]/g, ".")
|
|
104
|
+
: undefined,
|
|
105
|
+
eTag: object.ETag,
|
|
106
|
+
versionId: object.VersionId,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const parser = new SSPMParser(bytes);
|
|
110
|
+
|
|
111
|
+
parsedData = parser.parse();
|
|
112
|
+
console.log("createBeatmap parsed SSPM", {
|
|
113
|
+
attempt: attempt + 1,
|
|
114
|
+
mapID: parsedData.strings.mapID,
|
|
115
|
+
noteCount: parsedData.metadata.noteCount,
|
|
116
|
+
markerCount: parsedData.metadata.markerCount,
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
parseError = error;
|
|
121
|
+
console.log("createBeatmap parse failed", {
|
|
122
|
+
attempt: attempt + 1,
|
|
123
|
+
error: error?.toString?.() || String(error),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (attempt < 3) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!parsedData) throw parseError;
|
|
111
133
|
|
|
112
|
-
const parsedData = parser.parse();
|
|
113
134
|
const digested = parsedData.strings.mapID;
|
|
114
135
|
|
|
115
136
|
const user = (await getUserBySession(session)) as User;
|
|
@@ -169,14 +190,11 @@ export async function handler({
|
|
|
169
190
|
const cover = parsedData.cover || Buffer.from([]);
|
|
170
191
|
const coverContentType = getCoverContentType(cover);
|
|
171
192
|
|
|
172
|
-
await
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
ContentType: coverContentType,
|
|
178
|
-
})
|
|
179
|
-
);
|
|
193
|
+
await putStaticObject({
|
|
194
|
+
key: imgkey,
|
|
195
|
+
body: cover,
|
|
196
|
+
contentType: coverContentType,
|
|
197
|
+
});
|
|
180
198
|
|
|
181
199
|
const markers = parsedData.markers.sort((a, b) => a.position - b.position);
|
|
182
200
|
|
package/api/editProfile.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
75
|
-
data.data.username = removeZeroWidth(data.data.username);
|
|
71
|
+
const validationError = normalizeProfileUpdateData(data.data);
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
|
|
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;
|