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.
- package/api/createBeatmap.ts +64 -40
- package/api/editProfile.ts +4 -67
- package/api/executeAdminOperation.ts +637 -27
- 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/getChangelog.ts +46 -0
- package/api/getCollection.ts +44 -31
- package/api/getMapUploadUrl.ts +90 -93
- package/api/getProfile.ts +297 -297
- 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 +7 -4
- package/index.ts +193 -162
- package/package.json +7 -3
- 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/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/linked-project.json +1 -0
- package/types/database.ts +1702 -1450
- package/utils/beatmapFiles.ts +102 -0
- package/utils/beatmapHash.ts +239 -0
- package/utils/beatmapTopScores.ts +68 -84
- package/utils/getUserBySession.ts +3 -1
- package/utils/moderation.ts +101 -0
- package/utils/profileUpdateValidation.ts +51 -0
- package/utils/redis.ts +24 -0
- package/utils/requestUtils.ts +2 -2
- package/utils/rhrReplay.ts +122 -0
- package/utils/star-calc/formatSingle.ts +107 -0
- package/utils/star-calc/rhmParser.ts +214 -0
- package/utils/star-calc/sspmParser.ts +294 -160
- package/worker.ts +197 -195
- 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
|
+
}
|