rhythia-api 241.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 -56
- package/api/editProfileFriend.ts +122 -0
- 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/getFriends.ts +154 -0
- package/api/getMapUploadUrl.ts +90 -93
- package/api/getProfile.ts +195 -127
- 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 +183 -137
- package/package.json +5 -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 -1414
- 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/worker.ts +195 -191
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,59 +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
|
-
if (data.data.username.length < 3) {
|
|
76
|
-
return NextResponse.json(
|
|
77
|
-
{
|
|
78
|
-
error: "Username must be at least 3 characters long",
|
|
79
|
-
},
|
|
80
|
-
{ status: 404 }
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (data.data.username.length > 20) {
|
|
85
|
-
return NextResponse.json(
|
|
86
|
-
{
|
|
87
|
-
error: "Username too long.",
|
|
88
|
-
},
|
|
89
|
-
{ status: 404 }
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!/^[a-z0-9]+$/i.test(data.data.username)) {
|
|
94
|
-
return NextResponse.json(
|
|
95
|
-
{
|
|
96
|
-
error: "Username can only contain letters (a-z) and numbers (0-9)",
|
|
97
|
-
},
|
|
98
|
-
{ status: 404 }
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (validator.trim(data.data.username || "") !== (data.data.username || "")) {
|
|
104
|
-
return NextResponse.json(
|
|
105
|
-
{
|
|
106
|
-
error: "Username can't start or end with spaces.",
|
|
107
|
-
},
|
|
108
|
-
{ status: 404 }
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
data.data.username = removeZeroWidth(data.data.username || "");
|
|
113
|
-
|
|
114
|
-
if (data.data.flag !== undefined) {
|
|
115
|
-
const normalizedFlag = validator.trim(data.data.flag).toUpperCase();
|
|
116
|
-
|
|
117
|
-
if (!countryCodes.has(normalizedFlag as (typeof COUNTRY_LIST)[number])) {
|
|
118
|
-
return NextResponse.json(
|
|
119
|
-
{
|
|
120
|
-
error: "Flag must be a valid country code.",
|
|
121
|
-
},
|
|
122
|
-
{ status: 404 }
|
|
123
|
-
);
|
|
124
|
-
}
|
|
71
|
+
const validationError = normalizeProfileUpdateData(data.data);
|
|
125
72
|
|
|
126
|
-
|
|
73
|
+
if (validationError) {
|
|
74
|
+
return NextResponse.json({ error: validationError }, { status: 404 });
|
|
127
75
|
}
|
|
128
76
|
|
|
129
77
|
const user = (await getUserBySession(data.session)) as User;
|
|
@@ -0,0 +1,122 @@
|
|
|
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 { getUserBySession } from "../utils/getUserBySession";
|
|
6
|
+
import { User } from "@supabase/supabase-js";
|
|
7
|
+
|
|
8
|
+
const friendStateSchema = z.enum(["none", "added", "mutual"]);
|
|
9
|
+
|
|
10
|
+
export const Schema = {
|
|
11
|
+
input: z.strictObject({
|
|
12
|
+
session: z.string(),
|
|
13
|
+
profileId: z.number(),
|
|
14
|
+
action: z.enum(["add", "remove"]),
|
|
15
|
+
}),
|
|
16
|
+
output: z.strictObject({
|
|
17
|
+
error: z.string().optional(),
|
|
18
|
+
friend_count: z.number().optional(),
|
|
19
|
+
friend_state: friendStateSchema.optional(),
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function getFriendState(viewerProfileId: number, profileId: number) {
|
|
24
|
+
const [{ count: addedCount }, { count: mutualCount }] = await Promise.all([
|
|
25
|
+
supabase
|
|
26
|
+
.from("profileFriends")
|
|
27
|
+
.select("*", { count: "exact", head: true })
|
|
28
|
+
.eq("profile_id", viewerProfileId)
|
|
29
|
+
.eq("friend_id", profileId),
|
|
30
|
+
supabase
|
|
31
|
+
.from("profileFriends")
|
|
32
|
+
.select("*", { count: "exact", head: true })
|
|
33
|
+
.eq("profile_id", profileId)
|
|
34
|
+
.eq("friend_id", viewerProfileId),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
if (!addedCount) {
|
|
38
|
+
return "none";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return mutualCount ? "mutual" : "added";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
45
|
+
return protectedApi({
|
|
46
|
+
request,
|
|
47
|
+
schema: Schema,
|
|
48
|
+
authorization: validUser,
|
|
49
|
+
activity: handler,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function handler({
|
|
54
|
+
session,
|
|
55
|
+
profileId,
|
|
56
|
+
action,
|
|
57
|
+
}: (typeof Schema)["input"]["_type"]): Promise<
|
|
58
|
+
NextResponse<(typeof Schema)["output"]["_type"]>
|
|
59
|
+
> {
|
|
60
|
+
const user = (await getUserBySession(session)) as User;
|
|
61
|
+
|
|
62
|
+
const { data: viewerProfile } = await supabase
|
|
63
|
+
.from("profiles")
|
|
64
|
+
.select("id")
|
|
65
|
+
.eq("uid", user.id)
|
|
66
|
+
.single();
|
|
67
|
+
|
|
68
|
+
if (!viewerProfile) {
|
|
69
|
+
return NextResponse.json({ error: "Can't find user" });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (viewerProfile.id === profileId) {
|
|
73
|
+
return NextResponse.json({ error: "You can't friend yourself." });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { data: targetProfile } = await supabase
|
|
77
|
+
.from("profiles")
|
|
78
|
+
.select("id")
|
|
79
|
+
.eq("id", profileId)
|
|
80
|
+
.single();
|
|
81
|
+
|
|
82
|
+
if (!targetProfile) {
|
|
83
|
+
return NextResponse.json({ error: "Player not found." });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const relation = {
|
|
87
|
+
profile_id: viewerProfile.id,
|
|
88
|
+
friend_id: targetProfile.id,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const mutation =
|
|
92
|
+
action === "add"
|
|
93
|
+
? await supabase
|
|
94
|
+
.from("profileFriends")
|
|
95
|
+
.upsert(relation, {
|
|
96
|
+
onConflict: "profile_id,friend_id",
|
|
97
|
+
ignoreDuplicates: true,
|
|
98
|
+
})
|
|
99
|
+
: await supabase
|
|
100
|
+
.from("profileFriends")
|
|
101
|
+
.delete()
|
|
102
|
+
.eq("profile_id", relation.profile_id)
|
|
103
|
+
.eq("friend_id", relation.friend_id);
|
|
104
|
+
|
|
105
|
+
if (mutation.error) {
|
|
106
|
+
return NextResponse.json({ error: mutation.error.message });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const [friendCount, friendState] = await Promise.all([
|
|
110
|
+
supabase
|
|
111
|
+
.from("profileFriends")
|
|
112
|
+
.select("*", { count: "exact", head: true })
|
|
113
|
+
.eq("friend_id", targetProfile.id)
|
|
114
|
+
.then((result) => result.count || 0),
|
|
115
|
+
getFriendState(viewerProfile.id, targetProfile.id),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
return NextResponse.json({
|
|
119
|
+
friend_count: friendCount,
|
|
120
|
+
friend_state: friendState,
|
|
121
|
+
});
|
|
122
|
+
}
|