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.
@@ -0,0 +1,102 @@
1
+ import {
2
+ GetObjectCommand,
3
+ PutObjectCommand,
4
+ S3Client,
5
+ } from "@aws-sdk/client-s3";
6
+ import { existsSync } from "node:fs";
7
+
8
+ if (existsSync(".env")) process.loadEnvFile?.(".env");
9
+ if (existsSync(".env.local")) process.loadEnvFile?.(".env.local");
10
+
11
+ export const beatmapBucket = "rhthia-avatars";
12
+
13
+ export const beatmapS3Client = 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
+ beatmapS3Client.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 function getStaticObjectKey(url: string) {
41
+ return decodeURIComponent(new URL(url).pathname.slice(1));
42
+ }
43
+
44
+ export async function readStaticObject(url: string) {
45
+ if (
46
+ !url.startsWith("https://static.rhythia.com/") ||
47
+ !process.env.ACCESS_BUCKET ||
48
+ !process.env.SECRET_BUCKET
49
+ ) {
50
+ const response = await fetch(url);
51
+ const bytes = Buffer.from(await response.arrayBuffer());
52
+
53
+ if (!response.ok) {
54
+ throw new Error(
55
+ `Failed to fetch beatmap file: ${response.status} ${response.statusText}`
56
+ );
57
+ }
58
+
59
+ return {
60
+ object: {
61
+ ContentType: response.headers.get("content-type") || undefined,
62
+ ContentLength: Number(response.headers.get("content-length")) || bytes.length,
63
+ ETag: response.headers.get("etag") || undefined,
64
+ },
65
+ objectKey: getStaticObjectKey(url),
66
+ bytes,
67
+ };
68
+ }
69
+
70
+ const objectKey = getStaticObjectKey(url);
71
+ const object = await beatmapS3Client.send(
72
+ new GetObjectCommand({
73
+ Bucket: beatmapBucket,
74
+ Key: objectKey,
75
+ })
76
+ );
77
+
78
+ return {
79
+ object,
80
+ objectKey,
81
+ bytes: Buffer.from(await object.Body!.transformToByteArray()),
82
+ };
83
+ }
84
+
85
+ export async function putStaticObject({
86
+ key,
87
+ body,
88
+ contentType,
89
+ }: {
90
+ key: string;
91
+ body: Uint8Array | Buffer;
92
+ contentType: string;
93
+ }) {
94
+ return beatmapS3Client.send(
95
+ new PutObjectCommand({
96
+ Bucket: beatmapBucket,
97
+ Key: key,
98
+ Body: body,
99
+ ContentType: contentType,
100
+ })
101
+ );
102
+ }
@@ -0,0 +1,336 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SSPMParser, type SSPMParsedMap } from "./star-calc/sspmParser.ts";
3
+ import { V1SSPMParser, type SSPMMap } from "./star-calc/sspmv1Parser.ts";
4
+
5
+ const IntrosortSizeThreshold = 16;
6
+ const GeneralScientificLowerExponentDotNet = -5;
7
+ const DecimalTieTolerance = Number.EPSILON * 4;
8
+
9
+ type Marker = SSPMParsedMap["markers"][number];
10
+
11
+ function formatSingle(value: number) {
12
+ value = Math.fround(value);
13
+
14
+ for (let precision = 1; precision <= 9; precision++) {
15
+ const rounded = Number(value.toPrecision(precision));
16
+ const formatted = formatGeneralDotNet(
17
+ breakDecimalTieDotNet(value, rounded, precision)
18
+ );
19
+
20
+ if (Math.fround(Number(formatted)) === value) {
21
+ return formatted;
22
+ }
23
+ }
24
+
25
+ return formatGeneralDotNet(value);
26
+ }
27
+
28
+ function formatGeneralDotNet(value: number) {
29
+ if (value === 0) {
30
+ return "0";
31
+ }
32
+
33
+ const formatted = value.toString();
34
+ const exponent = getDecimalExponent(value);
35
+
36
+ if (
37
+ formatted.includes("e") ||
38
+ exponent <= GeneralScientificLowerExponentDotNet
39
+ ) {
40
+ return formatExponential(value, getSignificantDigitCount(formatted));
41
+ }
42
+
43
+ return formatted;
44
+ }
45
+
46
+ function getDecimalExponent(value: number) {
47
+ return Math.floor(Math.log10(Math.abs(value)));
48
+ }
49
+
50
+ function getSignificantDigitCount(value: string) {
51
+ const [mantissa] = value.toLowerCase().split("e");
52
+ const digits = mantissa!.replace("-", "").replace(".", "").replace(/^0+/, "");
53
+ return Math.max(digits.length, 1);
54
+ }
55
+
56
+ function formatExponential(value: number, significantDigitCount: number) {
57
+ const [mantissa, exponent] = value
58
+ .toExponential(significantDigitCount - 1)
59
+ .split("e");
60
+ const exponentValue = Number(exponent);
61
+ const exponentSign = exponentValue < 0 ? "-" : "+";
62
+ const exponentDigits = Math.abs(exponentValue).toString().padStart(2, "0");
63
+ const normalizedMantissa = mantissa!
64
+ .replace(/(\.\d*?)0+$/, "$1")
65
+ .replace(/\.$/, "");
66
+
67
+ return `${normalizedMantissa}E${exponentSign}${exponentDigits}`;
68
+ }
69
+
70
+ function breakDecimalTieDotNet(
71
+ value: number,
72
+ rounded: number,
73
+ precision: number
74
+ ) {
75
+ if (rounded === value || Math.fround(rounded) !== value) {
76
+ return rounded;
77
+ }
78
+
79
+ const exponent = getDecimalExponent(rounded);
80
+ const step = 10 ** (exponent - precision + 1);
81
+ const alternate = Number(
82
+ (rounded > value ? rounded - step : rounded + step).toPrecision(precision)
83
+ );
84
+
85
+ if (alternate === rounded || Math.fround(alternate) !== value) {
86
+ return rounded;
87
+ }
88
+
89
+ const roundedDistance = Math.abs(value - rounded);
90
+ const alternateDistance = Math.abs(value - alternate);
91
+ const tolerance =
92
+ DecimalTieTolerance *
93
+ Math.max(1, Math.abs(value), Math.abs(rounded), Math.abs(alternate));
94
+
95
+ if (alternateDistance + tolerance < roundedDistance) {
96
+ return alternate;
97
+ }
98
+
99
+ if (roundedDistance + tolerance < alternateDistance) {
100
+ return rounded;
101
+ }
102
+
103
+ return hasEvenLastSignificantDigit(alternate, precision)
104
+ ? alternate
105
+ : rounded;
106
+ }
107
+
108
+ function hasEvenLastSignificantDigit(value: number, precision: number) {
109
+ const [mantissa] = Math.abs(value).toPrecision(precision).split("e");
110
+ const digits = mantissa!.replace(".", "").replace(/^0+/, "");
111
+ const lastDigit = Number(digits.at(-1));
112
+
113
+ return lastDigit % 2 === 0;
114
+ }
115
+
116
+ function compareMarkerPosition(a: Marker, b: Marker) {
117
+ return a.position - b.position;
118
+ }
119
+
120
+ function swap(items: Marker[], left: number, right: number) {
121
+ const temp = items[left]!;
122
+ items[left] = items[right]!;
123
+ items[right] = temp;
124
+ }
125
+
126
+ function swapIfGreater(items: Marker[], left: number, right: number) {
127
+ if (
128
+ left !== right &&
129
+ compareMarkerPosition(items[left]!, items[right]!) > 0
130
+ ) {
131
+ swap(items, left, right);
132
+ }
133
+ }
134
+
135
+ function insertionSort(items: Marker[], start: number, length: number) {
136
+ for (let i = start; i < start + length - 1; i++) {
137
+ const value = items[i + 1]!;
138
+ let j = i;
139
+
140
+ while (j >= start && compareMarkerPosition(value, items[j]!) < 0) {
141
+ items[j + 1] = items[j]!;
142
+ j--;
143
+ }
144
+
145
+ items[j + 1] = value;
146
+ }
147
+ }
148
+
149
+ function downHeap(
150
+ items: Marker[],
151
+ start: number,
152
+ index: number,
153
+ heapSize: number
154
+ ) {
155
+ const value = items[start + index - 1]!;
156
+ let child: number;
157
+
158
+ while ((child = 2 * index) <= heapSize) {
159
+ if (
160
+ child < heapSize &&
161
+ compareMarkerPosition(items[start + child - 1]!, items[start + child]!) <
162
+ 0
163
+ ) {
164
+ child++;
165
+ }
166
+
167
+ if (compareMarkerPosition(value, items[start + child - 1]!) >= 0) {
168
+ break;
169
+ }
170
+
171
+ items[start + index - 1] = items[start + child - 1]!;
172
+ index = child;
173
+ }
174
+
175
+ items[start + index - 1] = value;
176
+ }
177
+
178
+ function heapSort(items: Marker[], start: number, length: number) {
179
+ for (let i = Math.floor(length / 2); i >= 1; i--) {
180
+ downHeap(items, start, i, length);
181
+ }
182
+
183
+ for (let i = length; i > 1; i--) {
184
+ swap(items, start, start + i - 1);
185
+ downHeap(items, start, 1, i - 1);
186
+ }
187
+ }
188
+
189
+ function pickPivotAndPartition(items: Marker[], start: number, length: number) {
190
+ const high = start + length - 1;
191
+ const middle = start + ((high - start) >> 1);
192
+
193
+ swapIfGreater(items, start, middle);
194
+ swapIfGreater(items, start, high);
195
+ swapIfGreater(items, middle, high);
196
+
197
+ const pivot = items[middle]!;
198
+ swap(items, middle, high - 1);
199
+
200
+ let left = start;
201
+ let right = high - 1;
202
+
203
+ while (left < right) {
204
+ while (compareMarkerPosition(items[++left]!, pivot) < 0) {}
205
+ while (compareMarkerPosition(pivot, items[--right]!) < 0) {}
206
+
207
+ if (left >= right) {
208
+ break;
209
+ }
210
+
211
+ swap(items, left, right);
212
+ }
213
+
214
+ if (left !== high - 1) {
215
+ swap(items, left, high - 1);
216
+ }
217
+
218
+ return left;
219
+ }
220
+
221
+ function introSort(
222
+ items: Marker[],
223
+ start: number,
224
+ length: number,
225
+ depthLimit: number
226
+ ) {
227
+ let partitionSize = length;
228
+
229
+ while (partitionSize > 1) {
230
+ if (partitionSize <= IntrosortSizeThreshold) {
231
+ if (partitionSize === 2) {
232
+ swapIfGreater(items, start, start + 1);
233
+ return;
234
+ }
235
+
236
+ if (partitionSize === 3) {
237
+ swapIfGreater(items, start, start + 1);
238
+ swapIfGreater(items, start, start + 2);
239
+ swapIfGreater(items, start + 1, start + 2);
240
+ return;
241
+ }
242
+
243
+ insertionSort(items, start, partitionSize);
244
+ return;
245
+ }
246
+
247
+ if (depthLimit === 0) {
248
+ heapSort(items, start, partitionSize);
249
+ return;
250
+ }
251
+
252
+ depthLimit--;
253
+ const pivot = pickPivotAndPartition(items, start, partitionSize);
254
+ introSort(
255
+ items,
256
+ pivot + 1,
257
+ start + partitionSize - (pivot + 1),
258
+ depthLimit
259
+ );
260
+ partitionSize = pivot - start;
261
+ }
262
+ }
263
+
264
+ function sortByPositionDotNet(markers: Marker[]) {
265
+ const result = [...markers];
266
+
267
+ if (result.length > 1) {
268
+ const depthLimit = 2 * (Math.floor(Math.log2(result.length)) + 1);
269
+ introSort(result, 0, result.length, depthLimit);
270
+ }
271
+
272
+ return result;
273
+ }
274
+
275
+ export function computeBeatmapHash(map: SSPMParsedMap) {
276
+ const noteType = Math.max(
277
+ map.markerDefinitions.findIndex((x) => x.id === "ssp_note"),
278
+ 0
279
+ );
280
+ const customDifficultyName =
281
+ map.customData.fields.find((x) => x.id === "difficulty_name")?.value ?? "";
282
+
283
+ const hashString =
284
+ map.strings.mapName +
285
+ map.strings.mapName +
286
+ map.strings.mappers.join(",") +
287
+ map.metadata.lastMarkerPos +
288
+ sortByPositionDotNet(map.markers.filter((x) => x.type === noteType))
289
+ .map(
290
+ (x) =>
291
+ x.position +
292
+ formatSingle(x.data.position.x) +
293
+ formatSingle(x.data.position.y)
294
+ )
295
+ .join(",") +
296
+ map.metadata.difficulty +
297
+ customDifficultyName;
298
+
299
+ return createHash("sha256").update(hashString, "utf16le").digest("hex");
300
+ }
301
+
302
+ export function computeV1BeatmapHash(map: SSPMMap) {
303
+ const hashString =
304
+ map.name +
305
+ map.name +
306
+ map.creator
307
+ .split(/[&,]/)
308
+ .map((x) => x.trim())
309
+ .join(",") +
310
+ map.lastNotePosition +
311
+ map.notes
312
+ .map(
313
+ (x) =>
314
+ x.position +
315
+ formatSingle(x.x) +
316
+ formatSingle(x.y)
317
+ )
318
+ .join(",") +
319
+ map.difficulty;
320
+
321
+ return createHash("sha256").update(hashString, "utf16le").digest("hex");
322
+ }
323
+
324
+ export function computeBeatmapHashFromBytes(bytes: Buffer) {
325
+ const version = bytes.readUInt16LE(4);
326
+
327
+ if (version === 1) {
328
+ return computeV1BeatmapHash(new V1SSPMParser(bytes).parse());
329
+ }
330
+
331
+ if (version === 2) {
332
+ return computeBeatmapHash(new SSPMParser(bytes).parse());
333
+ }
334
+
335
+ throw new Error(`Invalid SSPM version: ${version}`);
336
+ }
@@ -1,84 +1,68 @@
1
- import { Database } from "../types/database";
2
- import { getActiveProfileIdSet } from "./activityStatus";
3
- import { supabase } from "./supabase";
4
-
5
- type BeatmapTopScoreRow =
6
- Database["public"]["Functions"]["get_top_scores_for_beatmap3"]["Returns"][number];
7
-
8
- export type BeatmapTopScore = {
9
- id: number;
10
- awarded_sp: number | null;
11
- created_at: string;
12
- misses: number | null;
13
- mods: Record<string, unknown>;
14
- passed: boolean | null;
15
- songId: string | null;
16
- speed: number | null;
17
- spin: boolean;
18
- userId: number | null;
19
- username: string | null;
20
- avatar_url: string | null;
21
- accuracy: number | null;
22
- };
23
-
24
- type GetBeatmapTopScoresResult = {
25
- error?: string;
26
- scores: BeatmapTopScore[];
27
- };
28
-
29
- function mapBeatmapTopScore(score: BeatmapTopScoreRow): BeatmapTopScore {
30
- return {
31
- id: score.id,
32
- awarded_sp: score.awarded_sp,
33
- created_at: score.created_at,
34
- misses: score.misses,
35
- mods: (score.mods || {}) as Record<string, unknown>,
36
- passed: score.passed,
37
- songId: score.songId,
38
- speed: score.speed,
39
- spin: score.spin,
40
- userId: score.userId,
41
- username: score.username,
42
- avatar_url: score.avatar_url,
43
- accuracy: score.accuracy,
44
- };
45
- }
46
-
47
- export async function getVisibleTopScoresForBeatmap(
48
- beatmapHash: string,
49
- limit: number
50
- ): Promise<GetBeatmapTopScoresResult> {
51
- if (!beatmapHash) {
52
- return { scores: [] };
53
- }
54
-
55
- const { data: rpcScores, error } = await supabase.rpc(
56
- "get_top_scores_for_beatmap3",
57
- { beatmap_hash: beatmapHash }
58
- );
59
-
60
- if (error) {
61
- return { error: JSON.stringify(error), scores: [] };
62
- }
63
-
64
- const scoreData = rpcScores || [];
65
-
66
- const userIds = Array.from(
67
- new Set(
68
- scoreData
69
- .map((score) => score.userId)
70
- .filter((userId): userId is number => typeof userId === "number")
71
- )
72
- );
73
- const activeUserIds = await getActiveProfileIdSet(userIds);
74
-
75
- return {
76
- scores: scoreData
77
- .filter(
78
- (score) =>
79
- typeof score.userId === "number" && activeUserIds.has(score.userId)
80
- )
81
- .slice(0, limit)
82
- .map(mapBeatmapTopScore),
83
- };
84
- }
1
+ import { Database } from "../types/database";
2
+ import { supabase } from "./supabase";
3
+
4
+ type BeatmapTopScoreRow =
5
+ Database["public"]["Functions"]["get_top_scores_for_beatmap3"]["Returns"][number];
6
+
7
+ export type BeatmapTopScore = {
8
+ id: number;
9
+ awarded_sp: number | null;
10
+ created_at: string;
11
+ misses: number | null;
12
+ mods: Record<string, unknown>;
13
+ passed: boolean | null;
14
+ songId: string | null;
15
+ speed: number | null;
16
+ spin: boolean;
17
+ userId: number | null;
18
+ username: string | null;
19
+ avatar_url: string | null;
20
+ accuracy: number | null;
21
+ };
22
+
23
+ type GetBeatmapTopScoresResult = {
24
+ error?: string;
25
+ scores: BeatmapTopScore[];
26
+ };
27
+
28
+ function mapBeatmapTopScore(score: BeatmapTopScoreRow): BeatmapTopScore {
29
+ return {
30
+ id: score.id,
31
+ awarded_sp: score.awarded_sp,
32
+ created_at: score.created_at,
33
+ misses: score.misses,
34
+ mods: (score.mods || {}) as Record<string, unknown>,
35
+ passed: score.passed,
36
+ songId: score.songId,
37
+ speed: score.speed,
38
+ spin: score.spin,
39
+ userId: score.userId,
40
+ username: score.username,
41
+ avatar_url: score.avatar_url,
42
+ accuracy: score.accuracy,
43
+ };
44
+ }
45
+
46
+ export async function getVisibleTopScoresForBeatmap(
47
+ beatmapHash: string,
48
+ limit: number
49
+ ): Promise<GetBeatmapTopScoresResult> {
50
+ if (!beatmapHash) {
51
+ return { scores: [] };
52
+ }
53
+
54
+ const { data: rpcScores, error } = await supabase.rpc(
55
+ "get_top_scores_for_beatmap3",
56
+ { beatmap_hash: beatmapHash, score_limit: limit }
57
+ );
58
+
59
+ if (error) {
60
+ return { error: JSON.stringify(error), scores: [] };
61
+ }
62
+
63
+ const scoreData = rpcScores || [];
64
+
65
+ return {
66
+ scores: scoreData.slice(0, limit).map(mapBeatmapTopScore),
67
+ };
68
+ }
@@ -3,12 +3,14 @@ import { decryptString } from "./security";
3
3
  import { supabase } from "./supabase";
4
4
 
5
5
  export async function getUserBySession(session: string): Promise<User | null> {
6
- const user = (await supabase.auth.getUser(session)).data.user;
6
+ const getUserRes = (await supabase.auth.getUser(session))
7
+ const user = getUserRes.data.user;
7
8
  console.log("session:", session);
8
9
  if (user) {
9
10
  return user;
10
11
  }
11
12
 
13
+ console.log("USERERR:", getUserRes.error?.code, getUserRes.error?.message)
12
14
  try {
13
15
  console.log("trying legacy token");
14
16
  const decryptedToken = JSON.parse(decryptString(session)) as {
@@ -0,0 +1,51 @@
1
+ import validator from "validator";
2
+ import removeZeroWidth from "zero-width";
3
+ import { COUNTRY_LIST } from "./countryList";
4
+
5
+ const countryCodes = new Set(COUNTRY_LIST);
6
+
7
+ export type ProfileUpdateData = {
8
+ avatar_url?: string;
9
+ flag?: string;
10
+ profile_image?: string;
11
+ username?: string;
12
+ };
13
+
14
+ export function normalizeProfileUpdateData(data: ProfileUpdateData) {
15
+ if (data.username !== undefined) {
16
+ data.username = removeZeroWidth(data.username);
17
+
18
+ if (validator.trim(data.username) !== data.username) {
19
+ return "Username can't start or end with spaces.";
20
+ }
21
+
22
+ if (data.username.length < 2) {
23
+ return "Username must be at least 2 characters long";
24
+ }
25
+
26
+ if (data.username.length > 16) {
27
+ return "Username must be 16 characters or fewer.";
28
+ }
29
+
30
+ if (!/^[A-Za-z0-9._]+$/.test(data.username)) {
31
+ return "Username can only contain English letters (a-z), numbers (0-9), and up to one '_' or '.'";
32
+ }
33
+
34
+ const specialCharacters = data.username.match(/[._]/g) ?? [];
35
+ if (specialCharacters.length > 1) {
36
+ return "Username can contain at most one '_' or one '.'";
37
+ }
38
+ }
39
+
40
+ if (data.flag !== undefined) {
41
+ const normalizedFlag = validator.trim(data.flag).toUpperCase();
42
+
43
+ if (!countryCodes.has(normalizedFlag as (typeof COUNTRY_LIST)[number])) {
44
+ return "Flag must be a valid country code.";
45
+ }
46
+
47
+ data.flag = normalizedFlag;
48
+ }
49
+
50
+ return null;
51
+ }
package/utils/redis.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { createClient, RedisClientType } from "redis";
2
+
3
+ let redisClient: RedisClientType;
4
+
5
+ export const getRedis = async (): Promise<RedisClientType> => {
6
+ if (!redisClient) {
7
+ redisClient = createClient({
8
+ username: process.env.REDIS_USERNAME,
9
+ password: process.env.REDIS_PASSWORD,
10
+ socket: {
11
+ host: process.env.REDIS_HOST,
12
+ port: Number(process.env.REDIS_PORT),
13
+ },
14
+ });
15
+
16
+ redisClient.on("error", (err) => {
17
+ console.error("Redis Client Error", err);
18
+ });
19
+
20
+ await redisClient.connect();
21
+ }
22
+
23
+ return redisClient;
24
+ };