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.
@@ -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
- const request = await fetch(url);
109
- const bytes = await request.arrayBuffer();
110
- const parser = new SSPMParser(Buffer.from(bytes));
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 s3Client.send(
173
- new PutObjectCommand({
174
- Bucket: "rhthia-avatars",
175
- Key: imgkey,
176
- Body: cover,
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
 
@@ -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,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
- if (data.data.username !== undefined) {
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
- data.data.flag = normalizedFlag;
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
+ }