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.
@@ -1,85 +1,90 @@
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
-
6
- import {
7
- PutBucketCorsCommand,
8
- PutObjectCommand,
9
- S3Client,
10
- } from "@aws-sdk/client-s3";
11
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
12
- import { validateIntrinsicToken } from "../utils/validateToken";
13
- import { getUserBySession } from "../utils/getUserBySession";
14
- import { User } from "@supabase/supabase-js";
15
-
16
- const s3Client = new S3Client({
17
- region: "auto",
18
- endpoint: "https://s3.eu-central-003.backblazeb2.com",
19
- credentials: {
20
- secretAccessKey: process.env.SECRET_BUCKET || "",
21
- accessKeyId: process.env.ACCESS_BUCKET || "",
22
- },
23
- });
24
-
25
- export const Schema = {
26
- input: z.strictObject({
27
- session: z.string(),
28
- contentLength: z.number(),
29
- contentType: z.string(),
30
- intrinsicToken: z.string(),
31
- }),
32
- output: z.strictObject({
33
- error: z.string().optional(),
34
- url: z.string().optional(),
35
- objectKey: z.string().optional(),
36
- }),
37
- };
38
-
39
- export async function POST(request: Request): Promise<NextResponse> {
40
- return protectedApi({
41
- request,
42
- schema: Schema,
43
- authorization: validUser,
44
- activity: handler,
45
- });
46
- }
47
-
48
- export async function handler({
49
- session,
50
- contentLength,
51
- contentType,
52
- intrinsicToken,
53
- }: (typeof Schema)["input"]["_type"]): Promise<
54
- NextResponse<(typeof Schema)["output"]["_type"]>
55
- > {
56
- const user = (await getUserBySession(session)) as User;
57
-
58
- if (!validateIntrinsicToken(intrinsicToken)) {
59
- return NextResponse.json({
60
- error: "Invalid intrinsic token",
61
- });
62
- }
63
-
64
- if (contentLength > 5000000) {
65
- return NextResponse.json({
66
- error: "Max content length exceeded.",
67
- });
68
- }
69
-
70
- const key = `user-avatar-${Date.now()}-${user.id}`;
71
- const command = new PutObjectCommand({
72
- Bucket: "rhthia-avatars",
73
- Key: key,
74
- ContentLength: contentLength,
75
- ContentType: contentType,
76
- });
77
-
78
- const presigned = await getSignedUrl(s3Client, command, {
79
- expiresIn: 3600,
80
- });
81
- return NextResponse.json({
82
- url: presigned,
83
- objectKey: key,
84
- });
85
- }
1
+ import { NextResponse } from "../utils/response";
2
+ import z from "zod";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+
5
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
6
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
7
+ import { validateIntrinsicToken } from "../utils/validateToken";
8
+ import { getUserBySession } from "../utils/getUserBySession";
9
+ import { User } from "@supabase/supabase-js";
10
+
11
+ const allowedContentTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
12
+
13
+ const s3Client = 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
+ });
21
+
22
+ export const Schema = {
23
+ input: z.strictObject({
24
+ session: z.string(),
25
+ contentLength: z.number(),
26
+ contentType: z.string(),
27
+ intrinsicToken: z.string(),
28
+ }),
29
+ output: z.strictObject({
30
+ error: z.string().optional(),
31
+ url: z.string().optional(),
32
+ objectKey: z.string().optional(),
33
+ }),
34
+ };
35
+
36
+ export async function POST(request: Request): Promise<NextResponse> {
37
+ return protectedApi({
38
+ request,
39
+ schema: Schema,
40
+ authorization: validUser,
41
+ activity: handler,
42
+ });
43
+ }
44
+
45
+ export async function handler({
46
+ session,
47
+ contentLength,
48
+ contentType,
49
+ intrinsicToken,
50
+ }: (typeof Schema)["input"]["_type"]): Promise<
51
+ NextResponse<(typeof Schema)["output"]["_type"]>
52
+ > {
53
+ const user = (await getUserBySession(session)) as User;
54
+
55
+ if (!validateIntrinsicToken(intrinsicToken)) {
56
+ return NextResponse.json({
57
+ error: "Invalid intrinsic token",
58
+ });
59
+ }
60
+
61
+ if (contentLength > 5000000) {
62
+ return NextResponse.json({
63
+ error: "Max content length exceeded.",
64
+ });
65
+ }
66
+
67
+ if (!allowedContentTypes.has(contentType)) {
68
+ return NextResponse.json({
69
+ error: "Unacceptable format",
70
+ });
71
+ }
72
+
73
+ const key = `user-avatar-${Date.now()}-${user.id}`;
74
+ const command = new PutObjectCommand({
75
+ Bucket: "rhthia-avatars",
76
+ Key: key,
77
+ ContentLength: contentLength,
78
+ ContentType: contentType,
79
+ ContentDisposition: "attachment",
80
+ });
81
+
82
+ const presigned = await getSignedUrl(s3Client, command, {
83
+ expiresIn: 3600,
84
+ signableHeaders: new Set(["content-type"]),
85
+ });
86
+ return NextResponse.json({
87
+ url: presigned,
88
+ objectKey: key,
89
+ });
90
+ }
@@ -56,6 +56,7 @@ export const Schema = {
56
56
  description: z.string().nullable().optional(),
57
57
  tags: z.string().nullable().optional(),
58
58
  videoUrl: z.string().nullable().optional(),
59
+ mapHash: z.string().nullable().optional(),
59
60
  vetos: z
60
61
  .array(
61
62
  z.object({
@@ -173,6 +174,7 @@ export async function handler(
173
174
  description: beatmapPage.description,
174
175
  tags: beatmapPage.tags,
175
176
  videoUrl: beatmapPage.video_url,
177
+ mapHash: beatmapPage.mapHash,
176
178
  vetos: mappedVetos.length > 0 ? mappedVetos : undefined,
177
179
  },
178
180
  });
@@ -53,6 +53,7 @@ export const Schema = {
53
53
  qualified: z.boolean().nullable().optional(),
54
54
  qualifiedAt: z.string().nullable().optional(),
55
55
  videoUrl: z.string().nullable(),
56
+ mapHash: z.string().nullable().optional(),
56
57
  vetos: z
57
58
  .array(
58
59
  z.object({
@@ -166,6 +167,7 @@ export async function handler(
166
167
  qualifiedAt: beatmapPage.qualifiedAt,
167
168
  nominations: beatmapPage.nominations as number[],
168
169
  videoUrl: beatmapPage.video_url,
170
+ mapHash: beatmapPage.mapHash,
169
171
  vetos: mappedVetos.length > 0 ? mappedVetos : undefined,
170
172
  },
171
173
  });
@@ -1,197 +1,110 @@
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
- .array(
27
- z.object({
28
- id: z.number(),
29
- playcount: z.number().nullable().optional(),
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(),
39
- owner: z.number().nullable().optional(),
40
- ownerUsername: z.string().nullable().optional(),
41
- ownerAvatar: z.string().nullable().optional(),
42
- status: z.string().nullable().optional(),
43
- qualified: z.boolean().nullable().optional(),
44
- tags: z.string().nullable().optional(),
45
- videoUrl: z.string().nullable().optional(),
46
- })
47
- )
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
-
70
- export async function getBeatmaps(data: (typeof Schema)["input"]["_type"]) {
71
- const startPage = (data.page - 1) * VIEW_PER_PAGE;
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.
74
- const statusFilter = data.status?.toUpperCase();
75
- let countQry = supabase
76
- .from("beatmapPages")
77
- .select(
78
- `
79
- id,
80
- beatmaps!inner(
81
- title,
82
- starRating,
83
- length
84
- ),
85
- profiles!inner(
86
- username
87
- )
88
- `,
89
- { count: "exact", head: true }
90
- );
91
-
92
- let qry = supabase.from("beatmapPages").select(
93
- `
94
- owner,
95
- created_at,
96
- id,
97
- status,
98
- qualified,
99
- tags,
100
- ranked_at,
101
- video_url,
102
- beatmaps!inner(
103
- playcount,
104
- ranked,
105
- beatmapFile,
106
- image,
107
- starRating,
108
- difficulty,
109
- length,
110
- title
111
- ),
112
- profiles!inner(
113
- username
114
- )`
115
- );
116
-
117
- if (statusFilter === "RANKED") {
118
- qry = qry.order("ranked_at", { ascending: false });
119
- } else {
120
- qry = qry.order("created_at", { ascending: false });
121
- }
122
-
123
- if (data.textFilter) {
124
- qry = qry.ilike("beatmaps.title", `%${data.textFilter}%`);
125
- countQry = countQry.ilike("beatmaps.title", `%${data.textFilter}%`);
126
- }
127
-
128
- if (data.authorFilter) {
129
- qry = qry.ilike("profiles.username", `%${data.authorFilter}%`);
130
- countQry = countQry.ilike("profiles.username", `%${data.authorFilter}%`);
131
- }
132
-
133
- if (data.tagsFilter) {
134
- qry = qry.ilike("tags", `%${data.tagsFilter}%`);
135
- countQry = countQry.ilike("tags", `%${data.tagsFilter}%`);
136
- }
137
-
138
- if (data.minStars) {
139
- qry = qry.gt("beatmaps.starRating", data.minStars);
140
- countQry = countQry.gt("beatmaps.starRating", data.minStars);
141
- }
142
-
143
- if (data.maxStars) {
144
- qry = qry.lt("beatmaps.starRating", data.maxStars);
145
- countQry = countQry.lt("beatmaps.starRating", data.maxStars);
146
- }
147
-
148
- if (data.minLength) {
149
- qry = qry.gt("beatmaps.length", data.minLength);
150
- countQry = countQry.gt("beatmaps.length", data.minLength);
151
- }
152
-
153
- if (data.maxLength) {
154
- qry = qry.lt("beatmaps.length", data.maxLength);
155
- countQry = countQry.lt("beatmaps.length", data.maxLength);
156
- }
157
-
158
- if (statusFilter === "QUALIFIED") {
159
- qry = qry.eq("qualified", true);
160
- countQry = countQry.eq("qualified", true);
161
- } else if (data.status) {
162
- qry = qry.eq("status", data.status);
163
- countQry = countQry.eq("status", data.status);
164
- }
165
-
166
- if (data.creator !== undefined) {
167
- qry = qry.eq("owner", data.creator);
168
- countQry = countQry.eq("owner", data.creator);
169
- }
170
-
171
- const countQuery = await countQry;
172
- let queryData = await qry.range(startPage, endPage);
173
-
174
- return {
175
- total: countQuery.count || 0,
176
- viewPerPage: VIEW_PER_PAGE,
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,
189
- starRating: beatmapPage.beatmaps?.starRating,
190
- owner: beatmapPage.owner,
191
- status: beatmapPage.status,
192
- qualified: beatmapPage.qualified,
193
- ownerUsername: beatmapPage.profiles?.username,
194
- videoUrl: beatmapPage.video_url,
195
- })),
196
- };
197
- }
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
+ .array(
27
+ z.object({
28
+ id: z.number(),
29
+ playcount: z.number().nullable().optional(),
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(),
39
+ owner: z.number().nullable().optional(),
40
+ ownerUsername: z.string().nullable().optional(),
41
+ ownerAvatar: z.string().nullable().optional(),
42
+ status: z.string().nullable().optional(),
43
+ qualified: z.boolean().nullable().optional(),
44
+ tags: z.string().nullable().optional(),
45
+ videoUrl: z.string().nullable().optional(),
46
+ mapHash: z.string().nullable().optional(),
47
+ })
48
+ )
49
+ .optional(),
50
+ }),
51
+ };
52
+
53
+ export async function POST(request: Request): Promise<NextResponse> {
54
+ return protectedApi({
55
+ request,
56
+ schema: Schema,
57
+ authorization: () => {},
58
+ activity: handler,
59
+ });
60
+ }
61
+
62
+ export async function handler(
63
+ data: (typeof Schema)["input"]["_type"]
64
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
65
+ const result = await getBeatmaps(data);
66
+ return NextResponse.json(result);
67
+ }
68
+
69
+ const VIEW_PER_PAGE = 50;
70
+
71
+ export async function getBeatmaps(data: (typeof Schema)["input"]["_type"]) {
72
+ const { data: beatmaps } = await supabase.rpc("get_beatmaps_v2", {
73
+ page_number: data.page,
74
+ items_per_page: VIEW_PER_PAGE,
75
+ text_filter: data.textFilter || undefined,
76
+ author_filter: data.authorFilter || undefined,
77
+ tags_filter: data.tagsFilter || undefined,
78
+ max_stars: data.maxStars || undefined,
79
+ min_length: data.minLength || undefined,
80
+ max_length: data.maxLength || undefined,
81
+ min_stars: data.minStars || undefined,
82
+ creator_filter: data.creator,
83
+ status_filter: data.status || undefined,
84
+ });
85
+
86
+ return {
87
+ total: beatmaps?.[0]?.total_count || 0,
88
+ viewPerPage: VIEW_PER_PAGE,
89
+ currentPage: data.page,
90
+ beatmaps: beatmaps?.map((beatmapPage) => ({
91
+ id: beatmapPage.id,
92
+ tags: beatmapPage.tags,
93
+ playcount: beatmapPage.playcount,
94
+ created_at: beatmapPage.created_at,
95
+ difficulty: beatmapPage.difficulty,
96
+ title: beatmapPage.title,
97
+ ranked: beatmapPage.ranked,
98
+ length: beatmapPage.length,
99
+ beatmapFile: beatmapPage.beatmap_file,
100
+ image: beatmapPage.image,
101
+ starRating: beatmapPage.star_rating,
102
+ owner: beatmapPage.owner,
103
+ status: beatmapPage.status,
104
+ qualified: beatmapPage.qualified,
105
+ ownerUsername: beatmapPage.owner_username,
106
+ videoUrl: beatmapPage.video_url,
107
+ mapHash: beatmapPage.map_hash,
108
+ })),
109
+ };
110
+ }
@@ -7,19 +7,22 @@ export const Schema = {
7
7
  input: z.strictObject({
8
8
  session: z.string(),
9
9
  collection: z.number(),
10
+ page: z.number().optional().default(1),
11
+ itemsPerPage: z.number().optional().default(30),
10
12
  }),
11
13
  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
- avatar_url: z.string().nullable(),
19
- }),
20
- isList: z.boolean(),
21
- beatmaps: z.array(
22
- z.object({
14
+ collection: z.object({
15
+ title: z.string(),
16
+ description: z.string(),
17
+ owner: z.object({
18
+ id: z.number(),
19
+ username: z.string(),
20
+ avatar_url: z.string().nullable(),
21
+ }),
22
+ isList: z.boolean(),
23
+ beatmapCount: z.number(),
24
+ beatmaps: z.array(
25
+ z.object({
23
26
  id: z.number(),
24
27
  playcount: z.number().nullable().optional(),
25
28
  created_at: z.string().nullable().optional(),
@@ -37,6 +40,7 @@ export const Schema = {
37
40
  })
38
41
  ),
39
42
  }),
43
+ totalPages: z.number(),
40
44
  error: z.string().optional(),
41
45
  }),
42
46
  };
@@ -51,18 +55,21 @@ export async function POST(request: Request) {
51
55
  }
52
56
 
53
57
  export async function handler(data: (typeof Schema)["input"]["_type"]) {
54
- let { data: queryCollectionData, error: collectionError } = await supabase
58
+ const from = (data.page - 1) * data.itemsPerPage;
59
+ const to = from + data.itemsPerPage - 1;
60
+
61
+ const { data: queryCollectionData } = await supabase
55
62
  .from("beatmapCollections")
56
63
  .select(
57
64
  `
58
- *,
59
- profiles!inner(
60
- id,
61
- username,
62
- avatar_url
63
- )
64
- `
65
- )
65
+ *,
66
+ profiles!inner(
67
+ id,
68
+ username,
69
+ avatar_url
70
+ )
71
+ `
72
+ )
66
73
  .eq("id", data.collection)
67
74
  .single();
68
75
 
@@ -70,7 +77,7 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
70
77
  return NextResponse.json({ error: "Can't find collection" });
71
78
  }
72
79
 
73
- let { data: queryBeatmaps, error: beatmapsError } = await supabase
80
+ const { data: queryBeatmaps, count } = await supabase
74
81
  .from("collectionRelations")
75
82
  .select(
76
83
  `
@@ -95,12 +102,16 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
95
102
  username
96
103
  )
97
104
  )
98
- `
105
+ `,
106
+ { count: "exact" }
99
107
  )
100
- .eq("collection", data.collection);
108
+ .eq("collection", data.collection)
109
+ .order("sort", { ascending: true })
110
+ .order("id", { ascending: true })
111
+ .range(from, to);
101
112
 
102
113
  const formattedBeatmaps =
103
- queryBeatmaps?.map((relation) => ({
114
+ queryBeatmaps?.map((relation: any) => ({
104
115
  id: relation.beatmapPages.id,
105
116
  playcount: relation.beatmapPages.beatmaps.playcount,
106
117
  created_at: relation.beatmapPages.created_at,
@@ -119,15 +130,17 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
119
130
 
120
131
  return NextResponse.json({
121
132
  collection: {
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,
133
+ owner: {
134
+ username: queryCollectionData.profiles.username,
135
+ id: queryCollectionData.profiles.id,
136
+ avatar_url: queryCollectionData.profiles.avatar_url,
137
+ },
138
+ isList: queryCollectionData.is_list,
139
+ beatmapCount: count || 0,
140
+ title: queryCollectionData.title,
129
141
  description: queryCollectionData.description,
130
142
  beatmaps: formattedBeatmaps,
131
143
  },
144
+ totalPages: Math.max(1, Math.ceil((count || 0) / data.itemsPerPage)),
132
145
  });
133
146
  }