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
@@ -0,0 +1,122 @@
1
+ const VERSION_EXTENDED_FIELDS = 20260125;
2
+ const VERSION_FAIL_TIME = 20260222;
3
+ const VERSION_BEATMAP_HASH = 20260517;
4
+ const REPLAY_FRAME_SIZE = 17;
5
+
6
+ export function parseReplaySubmitData(bytes: Buffer) {
7
+ let offset = 0;
8
+
9
+ const ensure = (length: number) => {
10
+ if (offset + length > bytes.length) {
11
+ throw new Error("Invalid replay file");
12
+ }
13
+ };
14
+
15
+ const skip = (length: number) => {
16
+ ensure(length);
17
+ offset += length;
18
+ };
19
+
20
+ const readU8 = () => {
21
+ ensure(1);
22
+ return bytes[offset++];
23
+ };
24
+
25
+ const readInt32 = () => {
26
+ ensure(4);
27
+ const value = bytes.readInt32LE(offset);
28
+ offset += 4;
29
+ return value;
30
+ };
31
+
32
+ const readFloat = () => {
33
+ ensure(4);
34
+ const value = bytes.readFloatLE(offset);
35
+ offset += 4;
36
+ return value;
37
+ };
38
+
39
+ const readString = () => {
40
+ let length = 0;
41
+ let shift = 0;
42
+
43
+ while (true) {
44
+ const byte = readU8();
45
+ length += (byte & 0x7f) * 2 ** shift;
46
+
47
+ if ((byte & 0x80) === 0) {
48
+ break;
49
+ }
50
+
51
+ shift += 7;
52
+ if (shift > 35) {
53
+ throw new Error("Invalid replay file");
54
+ }
55
+ }
56
+
57
+ ensure(length);
58
+ const value = bytes.toString("utf8", offset, offset + length);
59
+ offset += length;
60
+ return value;
61
+ };
62
+
63
+ const version = readInt32();
64
+
65
+ skip(8);
66
+ readString();
67
+ readString();
68
+ const onlineMapId = readInt32();
69
+ readInt32();
70
+ readString();
71
+
72
+ let mods = "[]";
73
+ let spin = false;
74
+ let speed = 1;
75
+
76
+ if (version >= VERSION_EXTENDED_FIELDS) {
77
+ readU8();
78
+ mods = readString();
79
+ spin = readU8() !== 0;
80
+ speed = readFloat();
81
+ skip(8);
82
+ }
83
+
84
+ readFloat();
85
+ const hits = readInt32();
86
+ const misses = readInt32();
87
+ readFloat();
88
+
89
+ let failTime: number | null = null;
90
+ if (version >= VERSION_FAIL_TIME) {
91
+ const value = readInt32();
92
+ failTime = value >= 0 ? value : null;
93
+ }
94
+
95
+ const beatmapHash =
96
+ version >= VERSION_BEATMAP_HASH ? readString() || null : null;
97
+ const frameCount = readInt32();
98
+
99
+ if (frameCount < 0) {
100
+ throw new Error("Invalid replay file");
101
+ }
102
+
103
+ ensure(frameCount * REPLAY_FRAME_SIZE);
104
+
105
+ const parsedMods = JSON.parse(mods || "[]");
106
+ if (!Array.isArray(parsedMods)) {
107
+ throw new Error("Invalid replay mods");
108
+ }
109
+
110
+ return {
111
+ beatmapHash,
112
+ onlineMapId,
113
+ misses,
114
+ hits,
115
+ speed,
116
+ mods: parsedMods,
117
+ spin,
118
+ replayBytes: bytes.toString("base64"),
119
+ pauses: null,
120
+ failTime,
121
+ };
122
+ }
@@ -0,0 +1,107 @@
1
+ const GeneralScientificLowerExponentDotNet = -5;
2
+ const DecimalTieTolerance = Number.EPSILON * 4;
3
+
4
+ export function formatSingle(value: number) {
5
+ value = Math.fround(value);
6
+
7
+ for (let precision = 1; precision <= 9; precision++) {
8
+ const rounded = Number(value.toPrecision(precision));
9
+ const formatted = formatGeneralDotNet(
10
+ breakDecimalTieDotNet(value, rounded, precision)
11
+ );
12
+
13
+ if (Math.fround(Number(formatted)) === value) {
14
+ return formatted;
15
+ }
16
+ }
17
+
18
+ return formatGeneralDotNet(value);
19
+ }
20
+
21
+ function formatGeneralDotNet(value: number) {
22
+ if (value === 0) {
23
+ return "0";
24
+ }
25
+
26
+ const formatted = value.toString();
27
+ const exponent = getDecimalExponent(value);
28
+
29
+ if (
30
+ formatted.includes("e") ||
31
+ exponent <= GeneralScientificLowerExponentDotNet
32
+ ) {
33
+ return formatExponential(value, getSignificantDigitCount(formatted));
34
+ }
35
+
36
+ return formatted;
37
+ }
38
+
39
+ function getDecimalExponent(value: number) {
40
+ return Math.floor(Math.log10(Math.abs(value)));
41
+ }
42
+
43
+ function getSignificantDigitCount(value: string) {
44
+ const [mantissa] = value.toLowerCase().split("e");
45
+ const digits = mantissa!.replace("-", "").replace(".", "").replace(/^0+/, "");
46
+ return Math.max(digits.length, 1);
47
+ }
48
+
49
+ function formatExponential(value: number, significantDigitCount: number) {
50
+ const [mantissa, exponent] = value
51
+ .toExponential(significantDigitCount - 1)
52
+ .split("e");
53
+ const exponentValue = Number(exponent);
54
+ const exponentSign = exponentValue < 0 ? "-" : "+";
55
+ const exponentDigits = Math.abs(exponentValue).toString().padStart(2, "0");
56
+ const normalizedMantissa = mantissa!
57
+ .replace(/(\.\d*?)0+$/, "$1")
58
+ .replace(/\.$/, "");
59
+
60
+ return `${normalizedMantissa}E${exponentSign}${exponentDigits}`;
61
+ }
62
+
63
+ function breakDecimalTieDotNet(
64
+ value: number,
65
+ rounded: number,
66
+ precision: number
67
+ ) {
68
+ if (rounded === value || Math.fround(rounded) !== value) {
69
+ return rounded;
70
+ }
71
+
72
+ const exponent = getDecimalExponent(rounded);
73
+ const step = 10 ** (exponent - precision + 1);
74
+ const alternate = Number(
75
+ (rounded > value ? rounded - step : rounded + step).toPrecision(precision)
76
+ );
77
+
78
+ if (alternate === rounded || Math.fround(alternate) !== value) {
79
+ return rounded;
80
+ }
81
+
82
+ const roundedDistance = Math.abs(value - rounded);
83
+ const alternateDistance = Math.abs(value - alternate);
84
+ const tolerance =
85
+ DecimalTieTolerance *
86
+ Math.max(1, Math.abs(value), Math.abs(rounded), Math.abs(alternate));
87
+
88
+ if (alternateDistance + tolerance < roundedDistance) {
89
+ return alternate;
90
+ }
91
+
92
+ if (roundedDistance + tolerance < alternateDistance) {
93
+ return rounded;
94
+ }
95
+
96
+ return hasEvenLastSignificantDigit(alternate, precision)
97
+ ? alternate
98
+ : rounded;
99
+ }
100
+
101
+ function hasEvenLastSignificantDigit(value: number, precision: number) {
102
+ const [mantissa] = Math.abs(value).toPrecision(precision).split("e");
103
+ const digits = mantissa!.replace(".", "").replace(/^0+/, "");
104
+ const lastDigit = Number(digits.at(-1));
105
+
106
+ return lastDigit % 2 === 0;
107
+ }
@@ -0,0 +1,214 @@
1
+ import { Buffer } from "buffer";
2
+ import { createHash } from "node:crypto";
3
+ import pako from "pako";
4
+ import { formatSingle } from "./formatSingle.ts";
5
+ import type { SSPMParsedMap } from "./sspmParser.ts";
6
+
7
+ const LocalFileHeaderSignature = 0x04034b50;
8
+ const CentralDirectorySignature = 0x02014b50;
9
+ const EndOfCentralDirectorySignature = 0x06054b50;
10
+
11
+ interface ZipEntry {
12
+ name: string;
13
+ method: number;
14
+ compressedSize: number;
15
+ localHeaderOffset: number;
16
+ }
17
+
18
+ interface RHMNote {
19
+ Time: number;
20
+ X: number;
21
+ Y: number;
22
+ }
23
+
24
+ export interface RHMMap {
25
+ OnlineId?: number;
26
+ OnlineStatus?: string;
27
+ LegacyId?: string;
28
+ SongName: string;
29
+ Mappers: string[];
30
+ Title: string;
31
+ Duration: number;
32
+ Difficulty: number;
33
+ CustomDifficultyName?: string;
34
+ StarRating?: number;
35
+ Notes: RHMNote[];
36
+ AudioFileName?: string;
37
+ ImagePath?: string;
38
+ }
39
+
40
+ export interface RHMParsedMap {
41
+ map: RHMMap;
42
+ audio?: Buffer;
43
+ cover?: Buffer;
44
+ }
45
+
46
+ export function isRHM(buffer: Buffer) {
47
+ return (
48
+ buffer.length >= 4 && buffer.readUInt32LE(0) === LocalFileHeaderSignature
49
+ );
50
+ }
51
+
52
+ export function parseRHM(buffer: Buffer): RHMParsedMap {
53
+ const entries = readZipEntries(buffer);
54
+ const mapEntry = entries.get("map");
55
+
56
+ if (!mapEntry) {
57
+ throw new Error("Missing RHM map entry.");
58
+ }
59
+
60
+ return {
61
+ map: JSON.parse(mapEntry.toString("utf8")),
62
+ audio: entries.get("audio"),
63
+ cover: entries.get("cover"),
64
+ };
65
+ }
66
+
67
+ export function rhmToSSPMParsedMap({ map, audio, cover }: RHMParsedMap) {
68
+ const notes = [...map.Notes].sort((a, b) => a.Time - b.Time);
69
+ const fields: SSPMParsedMap["customData"]["fields"] = [];
70
+
71
+ if (map.CustomDifficultyName) {
72
+ fields.push({
73
+ id: "difficulty_name",
74
+ type: 0x09,
75
+ value: map.CustomDifficultyName,
76
+ });
77
+ }
78
+
79
+ return {
80
+ header: {
81
+ signature: Buffer.from("RHM\0"),
82
+ version: 0,
83
+ reserved: Buffer.from([]),
84
+ },
85
+ metadata: {
86
+ sha1: Buffer.from([]),
87
+ lastMarkerPos: map.Duration,
88
+ noteCount: notes.length,
89
+ markerCount: notes.length,
90
+ difficulty: map.Difficulty,
91
+ rating: 0,
92
+ hasAudio: !!audio,
93
+ hasCover: !!cover,
94
+ requiresMod: false,
95
+ },
96
+ pointers: {
97
+ customDataOffset: 0,
98
+ customDataLength: 0,
99
+ audioOffset: 0,
100
+ audioLength: audio?.length || 0,
101
+ coverOffset: 0,
102
+ coverLength: cover?.length || 0,
103
+ markerDefinitionsOffset: 0,
104
+ markerDefinitionsLength: 0,
105
+ markerOffset: 0,
106
+ markerLength: 0,
107
+ },
108
+ strings: {
109
+ mapID: map.LegacyId || computeRhythiaMapHash(map),
110
+ mapName: map.Title,
111
+ songName: map.SongName,
112
+ mappers: map.Mappers,
113
+ },
114
+ customData: { fields },
115
+ audio,
116
+ cover,
117
+ markerDefinitions: [{ id: "ssp_note", values: [0x07] }],
118
+ markers: notes.map((note) => {
119
+ const position = { x: note.X, y: note.Y, type: "quantum" as const };
120
+
121
+ return {
122
+ position: note.Time,
123
+ type: 0,
124
+ data: {
125
+ field0: position,
126
+ position,
127
+ },
128
+ };
129
+ }),
130
+ } satisfies SSPMParsedMap;
131
+ }
132
+
133
+ export function computeRhythiaMapHash(map: RHMMap) {
134
+ const hashString =
135
+ map.Title +
136
+ map.SongName +
137
+ map.Mappers.join(",") +
138
+ map.Duration +
139
+ map.Notes.map(
140
+ (x) => x.Time + formatSingle(x.X) + formatSingle(x.Y)
141
+ ).join(",") +
142
+ map.Difficulty +
143
+ (map.CustomDifficultyName ?? "");
144
+
145
+ return createHash("sha256").update(hashString, "utf16le").digest("hex");
146
+ }
147
+
148
+ function readZipEntries(buffer: Buffer) {
149
+ const eocdOffset = findEndOfCentralDirectory(buffer);
150
+ const entryCount = buffer.readUInt16LE(eocdOffset + 10);
151
+ let offset = buffer.readUInt32LE(eocdOffset + 16);
152
+ const entries = new Map<string, Buffer>();
153
+
154
+ for (let i = 0; i < entryCount; i++) {
155
+ const entry = readCentralDirectoryEntry(buffer, offset);
156
+ entries.set(entry.name, readZipEntry(buffer, entry));
157
+
158
+ offset +=
159
+ 46 +
160
+ buffer.readUInt16LE(offset + 28) +
161
+ buffer.readUInt16LE(offset + 30) +
162
+ buffer.readUInt16LE(offset + 32);
163
+ }
164
+
165
+ return entries;
166
+ }
167
+
168
+ function findEndOfCentralDirectory(buffer: Buffer) {
169
+ for (let i = buffer.length - 22; i >= 0; i--) {
170
+ if (buffer.readUInt32LE(i) === EndOfCentralDirectorySignature) {
171
+ return i;
172
+ }
173
+ }
174
+
175
+ throw new Error("Invalid RHM zip directory.");
176
+ }
177
+
178
+ function readCentralDirectoryEntry(buffer: Buffer, offset: number): ZipEntry {
179
+ if (buffer.readUInt32LE(offset) !== CentralDirectorySignature) {
180
+ throw new Error("Invalid RHM central directory.");
181
+ }
182
+
183
+ const nameLength = buffer.readUInt16LE(offset + 28);
184
+
185
+ return {
186
+ name: buffer.subarray(offset + 46, offset + 46 + nameLength).toString(),
187
+ method: buffer.readUInt16LE(offset + 10),
188
+ compressedSize: buffer.readUInt32LE(offset + 20),
189
+ localHeaderOffset: buffer.readUInt32LE(offset + 42),
190
+ };
191
+ }
192
+
193
+ function readZipEntry(buffer: Buffer, entry: ZipEntry) {
194
+ const localHeaderOffset = entry.localHeaderOffset;
195
+
196
+ if (buffer.readUInt32LE(localHeaderOffset) !== LocalFileHeaderSignature) {
197
+ throw new Error(`Invalid RHM entry header: ${entry.name}`);
198
+ }
199
+
200
+ const nameLength = buffer.readUInt16LE(localHeaderOffset + 26);
201
+ const extraLength = buffer.readUInt16LE(localHeaderOffset + 28);
202
+ const dataStart = localHeaderOffset + 30 + nameLength + extraLength;
203
+ const data = buffer.subarray(dataStart, dataStart + entry.compressedSize);
204
+
205
+ if (entry.method === 0) {
206
+ return data;
207
+ }
208
+
209
+ if (entry.method === 8) {
210
+ return Buffer.from(pako.inflateRaw(data));
211
+ }
212
+
213
+ throw new Error(`Unsupported RHM zip compression: ${entry.method}`);
214
+ }