rhythia-api 235.0.0 → 237.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.
@@ -1,41 +1,41 @@
1
- import { NextResponse } from "../utils/response";
2
- import z from "zod";
3
- import { protectedApi } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
5
-
6
- export const Schema = {
7
- input: z.strictObject({
8
- session: z.string(),
9
- textFilter: z.string().optional(),
10
- authorFilter: z.string().optional(),
11
- tagsFilter: z.string().optional(),
12
- page: z.number().default(1),
13
- maxStars: z.number().optional(),
14
- minLength: z.number().optional(),
15
- maxLength: z.number().optional(),
16
- minStars: z.number().optional(),
17
- creator: z.number().optional(),
18
- status: z.string().optional(),
19
- }),
20
- output: z.object({
21
- error: z.string().optional(),
22
- total: z.number(),
23
- viewPerPage: z.number(),
24
- currentPage: z.number(),
25
- beatmaps: z
1
+ import { NextResponse } from "../utils/response";
2
+ import z from "zod";
3
+ import { protectedApi } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+
6
+ export const Schema = {
7
+ input: z.strictObject({
8
+ session: z.string(),
9
+ textFilter: z.string().optional(),
10
+ authorFilter: z.string().optional(),
11
+ tagsFilter: z.string().optional(),
12
+ page: z.number().default(1),
13
+ maxStars: z.number().optional(),
14
+ minLength: z.number().optional(),
15
+ maxLength: z.number().optional(),
16
+ minStars: z.number().optional(),
17
+ creator: z.number().optional(),
18
+ status: z.string().optional(),
19
+ }),
20
+ output: z.object({
21
+ error: z.string().optional(),
22
+ total: z.number(),
23
+ viewPerPage: z.number(),
24
+ currentPage: z.number(),
25
+ beatmaps: z
26
26
  .array(
27
27
  z.object({
28
28
  id: z.number(),
29
29
  playcount: z.number().nullable().optional(),
30
30
  created_at: z.string().nullable().optional(),
31
- difficulty: z.number().nullable().optional(),
32
- noteCount: z.number().nullable().optional(),
33
- length: z.number().nullable().optional(),
34
- title: z.string().nullable().optional(),
35
- ranked: z.boolean().nullable().optional(),
36
- beatmapFile: z.string().nullable().optional(),
37
- image: z.string().nullable().optional(),
38
- starRating: z.number().nullable().optional(),
31
+ difficulty: z.number().nullable().optional(),
32
+ noteCount: z.number().nullable().optional(),
33
+ length: z.number().nullable().optional(),
34
+ title: z.string().nullable().optional(),
35
+ ranked: z.boolean().nullable().optional(),
36
+ beatmapFile: z.string().nullable().optional(),
37
+ image: z.string().nullable().optional(),
38
+ starRating: z.number().nullable().optional(),
39
39
  owner: z.number().nullable().optional(),
40
40
  ownerUsername: z.string().nullable().optional(),
41
41
  ownerAvatar: z.string().nullable().optional(),
@@ -46,30 +46,31 @@ export const Schema = {
46
46
  })
47
47
  )
48
48
  .optional(),
49
- }),
50
- };
51
-
52
- export async function POST(request: Request): Promise<NextResponse> {
53
- return protectedApi({
54
- request,
55
- schema: Schema,
56
- authorization: () => {},
57
- activity: handler,
58
- });
59
- }
60
-
61
- export async function handler(
62
- data: (typeof Schema)["input"]["_type"]
63
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
64
- const result = await getBeatmaps(data);
65
- return NextResponse.json(result);
66
- }
67
-
68
- const VIEW_PER_PAGE = 50;
69
-
49
+ }),
50
+ };
51
+
52
+ export async function POST(request: Request): Promise<NextResponse> {
53
+ return protectedApi({
54
+ request,
55
+ schema: Schema,
56
+ authorization: () => {},
57
+ activity: handler,
58
+ });
59
+ }
60
+
61
+ export async function handler(
62
+ data: (typeof Schema)["input"]["_type"]
63
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
64
+ const result = await getBeatmaps(data);
65
+ return NextResponse.json(result);
66
+ }
67
+
68
+ const VIEW_PER_PAGE = 50;
69
+
70
70
  export async function getBeatmaps(data: (typeof Schema)["input"]["_type"]) {
71
71
  const startPage = (data.page - 1) * VIEW_PER_PAGE;
72
72
  const endPage = startPage + VIEW_PER_PAGE - 1;
73
+ // status filter is a bit uncanny, we store qualified as a field, but it acts as a status in the UI.
73
74
  const statusFilter = data.status?.toUpperCase();
74
75
  let countQry = supabase
75
76
  .from("beatmapPages")
@@ -100,14 +101,14 @@ export async function getBeatmaps(data: (typeof Schema)["input"]["_type"]) {
100
101
  video_url,
101
102
  beatmaps!inner(
102
103
  playcount,
103
- ranked,
104
- beatmapFile,
105
- image,
106
- starRating,
107
- difficulty,
108
- length,
109
- title
110
- ),
104
+ ranked,
105
+ beatmapFile,
106
+ image,
107
+ starRating,
108
+ difficulty,
109
+ length,
110
+ title
111
+ ),
111
112
  profiles!inner(
112
113
  username
113
114
  )`
@@ -173,18 +174,18 @@ export async function getBeatmaps(data: (typeof Schema)["input"]["_type"]) {
173
174
  return {
174
175
  total: countQuery.count || 0,
175
176
  viewPerPage: VIEW_PER_PAGE,
176
- currentPage: data.page,
177
- beatmaps: queryData.data?.map((beatmapPage) => ({
178
- id: beatmapPage.id,
179
- tags: beatmapPage.tags,
180
- playcount: beatmapPage.beatmaps?.playcount,
181
- created_at: beatmapPage.created_at,
182
- difficulty: beatmapPage.beatmaps?.difficulty,
183
- title: beatmapPage.beatmaps?.title,
184
- ranked: beatmapPage.beatmaps?.ranked,
185
- length: beatmapPage.beatmaps?.length,
186
- beatmapFile: beatmapPage.beatmaps?.beatmapFile,
187
- image: beatmapPage.beatmaps?.image,
177
+ currentPage: data.page,
178
+ beatmaps: queryData.data?.map((beatmapPage) => ({
179
+ id: beatmapPage.id,
180
+ tags: beatmapPage.tags,
181
+ playcount: beatmapPage.beatmaps?.playcount,
182
+ created_at: beatmapPage.created_at,
183
+ difficulty: beatmapPage.beatmaps?.difficulty,
184
+ title: beatmapPage.beatmaps?.title,
185
+ ranked: beatmapPage.beatmaps?.ranked,
186
+ length: beatmapPage.beatmaps?.length,
187
+ beatmapFile: beatmapPage.beatmaps?.beatmapFile,
188
+ image: beatmapPage.beatmaps?.image,
188
189
  starRating: beatmapPage.beatmaps?.starRating,
189
190
  owner: beatmapPage.owner,
190
191
  status: beatmapPage.status,
@@ -9,16 +9,17 @@ export const Schema = {
9
9
  collection: z.number(),
10
10
  }),
11
11
  output: z.object({
12
- collection: z.object({
13
- title: z.string(),
14
- description: z.string(),
15
- owner: z.object({
16
- id: z.number(),
17
- username: z.string(),
18
- }),
19
- isList: z.boolean(),
20
- beatmaps: z.array(
21
- z.object({
12
+ collection: z.object({
13
+ title: z.string(),
14
+ description: z.string(),
15
+ owner: z.object({
16
+ id: z.number(),
17
+ username: z.string(),
18
+ avatar_url: z.string().nullable(),
19
+ }),
20
+ isList: z.boolean(),
21
+ beatmaps: z.array(
22
+ z.object({
22
23
  id: z.number(),
23
24
  playcount: z.number().nullable().optional(),
24
25
  created_at: z.string().nullable().optional(),
@@ -54,13 +55,14 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
54
55
  .from("beatmapCollections")
55
56
  .select(
56
57
  `
57
- *,
58
- profiles!inner(
59
- id,
60
- username
61
- )
62
- `
63
- )
58
+ *,
59
+ profiles!inner(
60
+ id,
61
+ username,
62
+ avatar_url
63
+ )
64
+ `
65
+ )
64
66
  .eq("id", data.collection)
65
67
  .single();
66
68
 
@@ -117,12 +119,13 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
117
119
 
118
120
  return NextResponse.json({
119
121
  collection: {
120
- owner: {
121
- username: queryCollectionData.profiles.username,
122
- id: queryCollectionData.profiles.id,
123
- },
124
- isList: queryCollectionData.is_list,
125
- title: queryCollectionData.title,
122
+ owner: {
123
+ username: queryCollectionData.profiles.username,
124
+ id: queryCollectionData.profiles.id,
125
+ avatar_url: queryCollectionData.profiles.avatar_url,
126
+ },
127
+ isList: queryCollectionData.is_list,
128
+ title: queryCollectionData.title,
126
129
  description: queryCollectionData.description,
127
130
  beatmaps: formattedBeatmaps,
128
131
  },
@@ -0,0 +1,103 @@
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
+ import { postProfileReportWebhook } from "../utils/profileReportWebhook";
8
+
9
+ const MAX_DESCRIPTION_LENGTH = 1000;
10
+
11
+ export const Schema = {
12
+ input: z.strictObject({
13
+ session: z.string(),
14
+ profileId: z.number(),
15
+ description: z.string(),
16
+ }),
17
+ output: z.strictObject({
18
+ error: z.string().optional(),
19
+ id: z.number().optional(),
20
+ }),
21
+ };
22
+
23
+ export async function POST(request: Request): Promise<NextResponse> {
24
+ return protectedApi({
25
+ request,
26
+ schema: Schema,
27
+ authorization: validUser,
28
+ activity: handler,
29
+ });
30
+ }
31
+
32
+ export async function handler({
33
+ session,
34
+ profileId,
35
+ description,
36
+ }: (typeof Schema)["input"]["_type"]): Promise<
37
+ NextResponse<(typeof Schema)["output"]["_type"]>
38
+ > {
39
+ const trimmedDescription = description.trim();
40
+
41
+ if (!trimmedDescription.length) {
42
+ return NextResponse.json({ error: "Report description is required." });
43
+ }
44
+
45
+ if (trimmedDescription.length > MAX_DESCRIPTION_LENGTH) {
46
+ return NextResponse.json({
47
+ error: `Report description exceeds ${MAX_DESCRIPTION_LENGTH} characters.`,
48
+ });
49
+ }
50
+
51
+ const user = (await getUserBySession(session)) as User;
52
+ const { data: reporterProfile } = await supabase
53
+ .from("profiles")
54
+ .select("id,ban,username,computedUsername,avatar_url")
55
+ .eq("uid", user.id)
56
+ .single();
57
+
58
+ if (!reporterProfile) {
59
+ return NextResponse.json({ error: "Can't find user" });
60
+ }
61
+
62
+ if (reporterProfile.ban !== "cool") {
63
+ return NextResponse.json({ error: "Error" });
64
+ }
65
+
66
+ if (reporterProfile.id === profileId) {
67
+ return NextResponse.json({ error: "You can't report yourself." });
68
+ }
69
+
70
+ const { data: reportedProfile } = await supabase
71
+ .from("profiles")
72
+ .select("id,username,computedUsername,avatar_url")
73
+ .eq("id", profileId)
74
+ .single();
75
+
76
+ if (!reportedProfile) {
77
+ return NextResponse.json({ error: "Player not found." });
78
+ }
79
+
80
+ const insertResult = await supabase
81
+ .from("profileReports")
82
+ .insert({
83
+ reporter: reporterProfile.id,
84
+ reported: reportedProfile.id,
85
+ description: trimmedDescription,
86
+ })
87
+ .select("id,created_at")
88
+ .single();
89
+
90
+ if (insertResult.error) {
91
+ return NextResponse.json({ error: insertResult.error.message });
92
+ }
93
+
94
+ await postProfileReportWebhook({
95
+ reportId: insertResult.data.id,
96
+ reporter: reporterProfile,
97
+ reported: reportedProfile,
98
+ description: trimmedDescription,
99
+ createdAt: insertResult.data.created_at,
100
+ });
101
+
102
+ return NextResponse.json({ id: insertResult.data?.id });
103
+ }
package/index.ts CHANGED
@@ -624,39 +624,39 @@ export const getBeatmapPageById = handleApi({url:"/api/getBeatmapPageById",...Ge
624
624
  // ./api/getBeatmaps.ts API
625
625
 
626
626
  /*
627
- export const Schema = {
628
- input: z.strictObject({
629
- session: z.string(),
630
- textFilter: z.string().optional(),
631
- authorFilter: z.string().optional(),
632
- tagsFilter: z.string().optional(),
633
- page: z.number().default(1),
634
- maxStars: z.number().optional(),
635
- minLength: z.number().optional(),
636
- maxLength: z.number().optional(),
637
- minStars: z.number().optional(),
638
- creator: z.number().optional(),
639
- status: z.string().optional(),
640
- }),
641
- output: z.object({
642
- error: z.string().optional(),
643
- total: z.number(),
644
- viewPerPage: z.number(),
645
- currentPage: z.number(),
646
- beatmaps: z
627
+ export const Schema = {
628
+ input: z.strictObject({
629
+ session: z.string(),
630
+ textFilter: z.string().optional(),
631
+ authorFilter: z.string().optional(),
632
+ tagsFilter: z.string().optional(),
633
+ page: z.number().default(1),
634
+ maxStars: z.number().optional(),
635
+ minLength: z.number().optional(),
636
+ maxLength: z.number().optional(),
637
+ minStars: z.number().optional(),
638
+ creator: z.number().optional(),
639
+ status: z.string().optional(),
640
+ }),
641
+ output: z.object({
642
+ error: z.string().optional(),
643
+ total: z.number(),
644
+ viewPerPage: z.number(),
645
+ currentPage: z.number(),
646
+ beatmaps: z
647
647
  .array(
648
648
  z.object({
649
649
  id: z.number(),
650
650
  playcount: z.number().nullable().optional(),
651
651
  created_at: z.string().nullable().optional(),
652
- difficulty: z.number().nullable().optional(),
653
- noteCount: z.number().nullable().optional(),
654
- length: z.number().nullable().optional(),
655
- title: z.string().nullable().optional(),
656
- ranked: z.boolean().nullable().optional(),
657
- beatmapFile: z.string().nullable().optional(),
658
- image: z.string().nullable().optional(),
659
- starRating: z.number().nullable().optional(),
652
+ difficulty: z.number().nullable().optional(),
653
+ noteCount: z.number().nullable().optional(),
654
+ length: z.number().nullable().optional(),
655
+ title: z.string().nullable().optional(),
656
+ ranked: z.boolean().nullable().optional(),
657
+ beatmapFile: z.string().nullable().optional(),
658
+ image: z.string().nullable().optional(),
659
+ starRating: z.number().nullable().optional(),
660
660
  owner: z.number().nullable().optional(),
661
661
  ownerUsername: z.string().nullable().optional(),
662
662
  ownerAvatar: z.string().nullable().optional(),
@@ -667,7 +667,7 @@ export const Schema = {
667
667
  })
668
668
  )
669
669
  .optional(),
670
- }),
670
+ }),
671
671
  };*/
672
672
  import { Schema as GetBeatmaps } from "./api/getBeatmaps"
673
673
  export { Schema as SchemaGetBeatmaps } from "./api/getBeatmaps"
@@ -773,16 +773,17 @@ export const Schema = {
773
773
  collection: z.number(),
774
774
  }),
775
775
  output: z.object({
776
- collection: z.object({
777
- title: z.string(),
778
- description: z.string(),
779
- owner: z.object({
780
- id: z.number(),
781
- username: z.string(),
782
- }),
783
- isList: z.boolean(),
784
- beatmaps: z.array(
785
- z.object({
776
+ collection: z.object({
777
+ title: z.string(),
778
+ description: z.string(),
779
+ owner: z.object({
780
+ id: z.number(),
781
+ username: z.string(),
782
+ avatar_url: z.string().nullable(),
783
+ }),
784
+ isList: z.boolean(),
785
+ beatmaps: z.array(
786
+ z.object({
786
787
  id: z.number(),
787
788
  playcount: z.number().nullable().optional(),
788
789
  created_at: z.string().nullable().optional(),
@@ -1348,6 +1349,24 @@ import { Schema as RankMapsArchive } from "./api/rankMapsArchive"
1348
1349
  export { Schema as SchemaRankMapsArchive } from "./api/rankMapsArchive"
1349
1350
  export const rankMapsArchive = handleApi({url:"/api/rankMapsArchive",...RankMapsArchive})
1350
1351
 
1352
+ // ./api/reportProfile.ts API
1353
+
1354
+ /*
1355
+ export const Schema = {
1356
+ input: z.strictObject({
1357
+ session: z.string(),
1358
+ profileId: z.number(),
1359
+ description: z.string(),
1360
+ }),
1361
+ output: z.strictObject({
1362
+ error: z.string().optional(),
1363
+ id: z.number().optional(),
1364
+ }),
1365
+ };*/
1366
+ import { Schema as ReportProfile } from "./api/reportProfile"
1367
+ export { Schema as SchemaReportProfile } from "./api/reportProfile"
1368
+ export const reportProfile = handleApi({url:"/api/reportProfile",...ReportProfile})
1369
+
1351
1370
  // ./api/searchUsers.ts API
1352
1371
 
1353
1372
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "235.0.0",
3
+ "version": "237.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {
@@ -10,6 +10,7 @@
10
10
  "test": "tsx ./scripts/test.ts",
11
11
  "cache:clear-user-scores": "bun run scripts/clear-user-score-cache.ts",
12
12
  "db:optimize-user-scores": "bun run scripts/optimize-user-scores-indexes.ts",
13
+ "db:create-profile-reports": "node scripts/create-profile-reports-table.ts",
13
14
  "query-pull": "bun run scripts/pull-queries.ts",
14
15
  "query-push": "bun run scripts/deploy-queries.ts",
15
16
  "queries:pull": "bun run scripts/pull-queries.ts",
package/types/database.ts CHANGED
@@ -519,6 +519,45 @@ export type Database = {
519
519
  }
520
520
  Relationships: []
521
521
  }
522
+ profileReports: {
523
+ Row: {
524
+ created_at: string
525
+ description: string
526
+ id: number
527
+ reported: number
528
+ reporter: number
529
+ }
530
+ Insert: {
531
+ created_at?: string
532
+ description: string
533
+ id?: number
534
+ reported: number
535
+ reporter: number
536
+ }
537
+ Update: {
538
+ created_at?: string
539
+ description?: string
540
+ id?: number
541
+ reported?: number
542
+ reporter?: number
543
+ }
544
+ Relationships: [
545
+ {
546
+ foreignKeyName: "profileReports_reported_fkey"
547
+ columns: ["reported"]
548
+ isOneToOne: false
549
+ referencedRelation: "profiles"
550
+ referencedColumns: ["id"]
551
+ },
552
+ {
553
+ foreignKeyName: "profileReports_reporter_fkey"
554
+ columns: ["reporter"]
555
+ isOneToOne: false
556
+ referencedRelation: "profiles"
557
+ referencedColumns: ["id"]
558
+ },
559
+ ]
560
+ }
522
561
  profiles: {
523
562
  Row: {
524
563
  about_me: string | null
@@ -0,0 +1,180 @@
1
+ type ProfileReportWebhookProfile = {
2
+ id: number;
3
+ username: string | null;
4
+ computedUsername: string | null;
5
+ avatar_url: string | null;
6
+ };
7
+
8
+ function clampText(value: string, maxLength: number) {
9
+ const sanitized = value.replace(
10
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g,
11
+ ""
12
+ );
13
+
14
+ if (sanitized.length <= maxLength) {
15
+ return sanitized;
16
+ }
17
+
18
+ if (maxLength <= 3) {
19
+ return sanitized.slice(0, maxLength);
20
+ }
21
+
22
+ return `${sanitized.slice(0, maxLength - 3)}...`;
23
+ }
24
+
25
+ function getSafeHttpUrl(value: string | null | undefined) {
26
+ if (!value) {
27
+ return null;
28
+ }
29
+
30
+ try {
31
+ const parsed = new URL(value.trim());
32
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
33
+ return null;
34
+ }
35
+
36
+ const serialized = parsed.toString();
37
+ if (serialized.length > 2048) {
38
+ return null;
39
+ }
40
+
41
+ return serialized;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function getDisplayName(profile: ProfileReportWebhookProfile) {
48
+ return (
49
+ profile.username ||
50
+ profile.computedUsername ||
51
+ `User #${profile.id}`
52
+ );
53
+ }
54
+
55
+ function getPlayerUrl(profileId: number) {
56
+ return `https://www.rhythia.com/player/${profileId}`;
57
+ }
58
+
59
+ export async function postProfileReportWebhook({
60
+ reportId,
61
+ reporter,
62
+ reported,
63
+ description,
64
+ createdAt,
65
+ }: {
66
+ reportId: number;
67
+ reporter: ProfileReportWebhookProfile;
68
+ reported: ProfileReportWebhookProfile;
69
+ description: string;
70
+ createdAt?: string;
71
+ }) {
72
+ const webhookUrl = process.env.DISCORD_REPORT_WEBHOOK_URL;
73
+ if (!webhookUrl) {
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const reporterName = getDisplayName(reporter);
79
+ const reportedName = getDisplayName(reported);
80
+ const safeReporterAvatarUrl = getSafeHttpUrl(reporter.avatar_url);
81
+ const safeReportedAvatarUrl = getSafeHttpUrl(reported.avatar_url);
82
+
83
+ const embed: Record<string, any> = {
84
+ title: clampText(`Profile Report #${reportId}`, 256),
85
+ description: clampText(description, 4096),
86
+ color: 0xe67e22,
87
+ fields: [
88
+ {
89
+ name: "Reported Player",
90
+ value: clampText(
91
+ `${reportedName} (#${reported.id})\n${getPlayerUrl(reported.id)}`,
92
+ 1024
93
+ ),
94
+ inline: true,
95
+ },
96
+ {
97
+ name: "Reporter",
98
+ value: clampText(
99
+ `${reporterName} (#${reporter.id})\n${getPlayerUrl(reporter.id)}`,
100
+ 1024
101
+ ),
102
+ inline: true,
103
+ },
104
+ {
105
+ name: "Submitted At",
106
+ value: clampText(
107
+ new Date(createdAt || Date.now()).toUTCString(),
108
+ 1024
109
+ ),
110
+ inline: true,
111
+ },
112
+ ],
113
+ author: {
114
+ name: clampText(reporterName, 256),
115
+ url: getPlayerUrl(reporter.id),
116
+ icon_url: safeReporterAvatarUrl || "https://www.rhythia.com/unkimg.png",
117
+ },
118
+ footer: {
119
+ text: clampText(
120
+ `Reported player: ${reportedName} | Report ID: ${reportId}`,
121
+ 2048
122
+ ),
123
+ },
124
+ };
125
+
126
+ if (safeReportedAvatarUrl) {
127
+ embed.thumbnail = {
128
+ url: safeReportedAvatarUrl,
129
+ };
130
+ }
131
+
132
+ const payload = {
133
+ content: "New player report submitted.",
134
+ embeds: [embed],
135
+ };
136
+
137
+ let response = await fetch(webhookUrl, {
138
+ method: "POST",
139
+ headers: {
140
+ "Content-Type": "application/json",
141
+ },
142
+ body: JSON.stringify(payload),
143
+ });
144
+
145
+ if (
146
+ !response.ok &&
147
+ response.status === 400 &&
148
+ payload.embeds?.[0]?.thumbnail?.url
149
+ ) {
150
+ const retryPayload = {
151
+ ...payload,
152
+ embeds: payload.embeds.map((embed: any) => {
153
+ const clone = { ...embed };
154
+ delete clone.thumbnail;
155
+ return clone;
156
+ }),
157
+ };
158
+
159
+ response = await fetch(webhookUrl, {
160
+ method: "POST",
161
+ headers: {
162
+ "Content-Type": "application/json",
163
+ },
164
+ body: JSON.stringify(retryPayload),
165
+ });
166
+ }
167
+
168
+ if (!response.ok) {
169
+ const responseBody = await response.text();
170
+ console.log("Discord report webhook failed", {
171
+ reportId,
172
+ status: response.status,
173
+ statusText: response.statusText,
174
+ responseBody: clampText(responseBody || "-", 4000),
175
+ });
176
+ }
177
+ } catch (error) {
178
+ console.log("Failed to post profile report webhook", error);
179
+ }
180
+ }
package/worker.ts CHANGED
@@ -46,6 +46,7 @@ import { POST as getVideoUploadUrl } from "./api/getVideoUploadUrl";
46
46
  import { POST as postBeatmapComment } from "./api/postBeatmapComment";
47
47
  import { POST as qualifyMap } from "./api/qualifyMap";
48
48
  import { POST as rankMapsArchive } from "./api/rankMapsArchive";
49
+ import { POST as reportProfile } from "./api/reportProfile";
49
50
  import { POST as searchUsers } from "./api/searchUsers";
50
51
  import { POST as setPasskey } from "./api/setPasskey";
51
52
  import { POST as submitScore } from "./api/submitScore";
@@ -113,6 +114,7 @@ const apiRoutes: Record<string, RouteHandler> = {
113
114
  "/api/postBeatmapComment": postBeatmapComment,
114
115
  "/api/qualifyMap": qualifyMap,
115
116
  "/api/rankMapsArchive": rankMapsArchive,
117
+ "/api/reportProfile": reportProfile,
116
118
  "/api/searchUsers": searchUsers,
117
119
  "/api/setPasskey": setPasskey,
118
120
  "/api/submitScore": submitScore,