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.
package/api/getProfile.ts CHANGED
@@ -1,16 +1,18 @@
1
- import { geolocation } from "../utils/requestGeo";
2
- import { NextResponse } from "../utils/response";
3
- import z from "zod";
4
- import { Database } from "../types/database";
5
- import { protectedApi } from "../utils/requestUtils";
6
- import { supabase } from "../utils/supabase";
7
- import { getUserBySession } from "../utils/getUserBySession";
1
+ import { geolocation } from "../utils/requestGeo";
2
+ import { NextResponse } from "../utils/response";
3
+ import z from "zod";
4
+ import { Database } from "../types/database";
5
+ import { protectedApi } from "../utils/requestUtils";
6
+ import { supabase } from "../utils/supabase";
7
+ import { getUserBySession } from "../utils/getUserBySession";
8
8
  import { User } from "@supabase/supabase-js";
9
9
  import {
10
10
  getActivityStatusForUserId,
11
11
  getScoreActivityCutoffIso,
12
12
  } from "../utils/activityStatus";
13
13
 
14
+ const friendStateSchema = z.enum(["none", "added", "mutual"]);
15
+
14
16
  function getNextUsernameChangeAt(
15
17
  lastChangedAt: string | null | undefined
16
18
  ): string | null {
@@ -23,28 +25,73 @@ function getNextUsernameChangeAt(
23
25
  return nextAllowedAt.toISOString();
24
26
  }
25
27
 
28
+ async function getViewerProfileId(session: string) {
29
+ if (!session) {
30
+ return null;
31
+ }
32
+
33
+ const viewer = await getUserBySession(session);
34
+
35
+ if (!viewer) {
36
+ return null;
37
+ }
38
+
39
+ const { data: viewerProfile } = await supabase
40
+ .from("profiles")
41
+ .select("id")
42
+ .eq("uid", viewer.id)
43
+ .single();
44
+
45
+ return viewerProfile?.id ?? null;
46
+ }
47
+
48
+ async function getFriendState(viewerProfileId: number | null, profileId: number) {
49
+ if (viewerProfileId === null || viewerProfileId === profileId) {
50
+ return "none" as const;
51
+ }
52
+
53
+ const [{ count: addedCount }, { count: mutualCount }] = await Promise.all([
54
+ supabase
55
+ .from("profileFriends")
56
+ .select("*", { count: "exact", head: true })
57
+ .eq("profile_id", viewerProfileId)
58
+ .eq("friend_id", profileId),
59
+ supabase
60
+ .from("profileFriends")
61
+ .select("*", { count: "exact", head: true })
62
+ .eq("profile_id", profileId)
63
+ .eq("friend_id", viewerProfileId),
64
+ ]);
65
+
66
+ if (!addedCount) {
67
+ return "none" as const;
68
+ }
69
+
70
+ return mutualCount ? ("mutual" as const) : ("added" as const);
71
+ }
72
+
26
73
  export const Schema = {
27
74
  input: z.strictObject({
28
- session: z.string(),
29
- id: z.number().nullable().optional(),
30
- }),
31
- output: z.object({
32
- error: z.string().optional(),
33
- user: z
34
- .object({
35
- about_me: z.string().nullable(),
36
- avatar_url: z.string().nullable(),
37
- profile_image: z.string().nullable(),
38
- badges: z.any().nullable(),
39
- created_at: z.number().nullable(),
40
- flag: z.string().nullable(),
41
- id: z.number(),
42
- uid: z.string().nullable(),
43
- ban: z.string().nullable(),
44
- username: z.string().nullable(),
45
- verified: z.boolean().nullable(),
46
- verificationDeadline: z.number().nullable(),
47
- play_count: z.number().nullable(),
75
+ session: z.string(),
76
+ id: z.number().nullable().optional(),
77
+ }),
78
+ output: z.object({
79
+ error: z.string().optional(),
80
+ user: z
81
+ .object({
82
+ about_me: z.string().nullable(),
83
+ avatar_url: z.string().nullable(),
84
+ profile_image: z.string().nullable(),
85
+ badges: z.any().nullable(),
86
+ created_at: z.number().nullable(),
87
+ flag: z.string().nullable(),
88
+ id: z.number(),
89
+ uid: z.string().nullable(),
90
+ ban: z.string().nullable(),
91
+ username: z.string().nullable(),
92
+ verified: z.boolean().nullable(),
93
+ verificationDeadline: z.number().nullable(),
94
+ play_count: z.number().nullable(),
48
95
  skill_points: z.number().nullable(),
49
96
  squares_hit: z.number().nullable(),
50
97
  total_score: z.number().nullable(),
@@ -53,6 +100,8 @@ export const Schema = {
53
100
  activity_status: z.enum(["active", "inactive"]),
54
101
  is_online: z.boolean(),
55
102
  last_active_timestamp: z.number().nullable(),
103
+ friend_count: z.number(),
104
+ friend_state: friendStateSchema,
56
105
  can_change_flag: z.boolean(),
57
106
  next_username_change_at: z.string().nullable(),
58
107
  previous_usernames: z.array(
@@ -66,105 +115,122 @@ export const Schema = {
66
115
  id: z.number(),
67
116
  acronym: z.string(),
68
117
  })
69
- .optional()
70
- .nullable(),
71
- })
72
- .optional(),
73
- }),
74
- };
75
-
76
- export async function POST(request: Request): Promise<NextResponse> {
77
- return protectedApi({
78
- request,
79
- schema: Schema,
80
- authorization: () => {},
81
- activity: handler,
82
- });
83
- }
84
-
85
- export async function handler(
86
- data: (typeof Schema)["input"]["_type"],
87
- req: Request
88
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
89
- let profiles: Database["public"]["Tables"]["profiles"]["Row"][] = [];
90
- let isOnline = false;
91
- // Fetch by id
92
- if (data.id !== undefined && data.id !== null) {
93
- let { data: queryData, error } = await supabase
94
- .from("profiles")
95
- .select(`*,clans:clan(id,acronym)`)
96
- .eq("id", data.id);
97
-
98
- console.log(profiles, error);
99
-
100
- if (!queryData?.length) {
101
- return NextResponse.json(
102
- {
103
- error: "User not found",
104
- },
105
- { status: 404 }
106
- );
107
- }
108
-
109
- profiles = queryData;
110
- } else {
111
- // Fetch by session id
112
- const user = (await getUserBySession(data.session)) as User;
113
-
114
- if (user) {
115
- let { data: queryData, error } = await supabase
116
- .from("profiles")
117
- .select("*")
118
- .eq("uid", user.id);
119
-
120
- if (!queryData?.length) {
121
- const geo = geolocation(req);
122
- const data = await supabase.from("profiles").upsert({
123
- uid: user.id,
124
- about_me: "",
125
- avatar_url:
126
- "https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/user-avatar-1725309193296-72002e6b-321c-4f60-a692-568e0e75147d",
127
- badges: [],
128
- username: `user${Math.round(Math.random() * 900000 + 100000)}`,
129
- computedUsername: `user${Math.round(Math.random() * 900000 + 100000)}`.toLowerCase(),
130
- flag: (geo.country || "US").toUpperCase(),
131
- created_at: Date.now(),
132
- }).select(`
133
- *,clans:clan(id,acronym)`);
134
-
135
- profiles = data.data!;
136
- } else {
137
- profiles = queryData;
138
- }
139
- }
140
- }
141
-
142
- const user = profiles[0];
143
-
144
- const activityStatus = await getActivityStatusForUserId(user.id);
145
-
146
- const { data: activityData } = await supabase
147
- .from("profileActivities")
148
- .select("last_activity")
149
- .eq("uid", user.uid || "")
150
- .single();
118
+ .optional()
119
+ .nullable(),
120
+ })
121
+ .optional(),
122
+ }),
123
+ };
151
124
 
152
- const { data: usernameHistoryData } = await supabase
153
- .from("profileUsernames")
154
- .select("username,changed_at")
155
- .eq("profile_id", user.id)
156
- .order("changed_at", { ascending: false });
125
+ export async function POST(request: Request): Promise<NextResponse> {
126
+ return protectedApi({
127
+ request,
128
+ schema: Schema,
129
+ authorization: () => {},
130
+ activity: handler,
131
+ });
132
+ }
157
133
 
158
- const { data: flagHistoryData } = await supabase
159
- .from("profileFlags")
160
- .select("id")
161
- .eq("profile_id", user.id)
162
- .limit(1);
163
-
164
- //last 30 minutes
165
- if (activityData && activityData.last_activity) {
166
- isOnline = Date.now() - activityData.last_activity < 1800000;
167
- }
134
+ export async function handler(
135
+ data: (typeof Schema)["input"]["_type"],
136
+ req: Request
137
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
138
+ let profiles: Database["public"]["Tables"]["profiles"]["Row"][] = [];
139
+ let isOnline = false;
140
+ let viewerProfileId =
141
+ data.id !== undefined && data.id !== null
142
+ ? await getViewerProfileId(data.session)
143
+ : null;
144
+ // Fetch by id
145
+ if (data.id !== undefined && data.id !== null) {
146
+ let { data: queryData, error } = await supabase
147
+ .from("profiles")
148
+ .select(`*,clans:clan(id,acronym)`)
149
+ .eq("id", data.id);
150
+
151
+ console.log(profiles, error);
152
+
153
+ if (!queryData?.length) {
154
+ return NextResponse.json(
155
+ {
156
+ error: "User not found",
157
+ },
158
+ { status: 404 }
159
+ );
160
+ }
161
+
162
+ profiles = queryData;
163
+ } else {
164
+ // Fetch by session id
165
+ const user = (await getUserBySession(data.session)) as User;
166
+
167
+ if (user) {
168
+ let { data: queryData, error } = await supabase
169
+ .from("profiles")
170
+ .select("*")
171
+ .eq("uid", user.id);
172
+
173
+ if (!queryData?.length) {
174
+ const geo = geolocation(req);
175
+ const data = await supabase.from("profiles").upsert({
176
+ uid: user.id,
177
+ about_me: "",
178
+ avatar_url:
179
+ "https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/user-avatar-1725309193296-72002e6b-321c-4f60-a692-568e0e75147d",
180
+ badges: [],
181
+ username: `user${Math.round(Math.random() * 900000 + 100000)}`,
182
+ computedUsername: `user${Math.round(Math.random() * 900000 + 100000)}`.toLowerCase(),
183
+ flag: (geo.country || "US").toUpperCase(),
184
+ created_at: Date.now(),
185
+ }).select(`
186
+ *,clans:clan(id,acronym)`);
187
+
188
+ profiles = data.data!;
189
+ } else {
190
+ profiles = queryData;
191
+ }
192
+
193
+ viewerProfileId = profiles[0]?.id ?? null;
194
+ }
195
+ }
196
+
197
+ const user = profiles[0];
198
+
199
+ const activityStatus = await getActivityStatusForUserId(user.id);
200
+
201
+ const [
202
+ { data: activityData },
203
+ { data: usernameHistoryData },
204
+ { data: flagHistoryData },
205
+ { count: friendCount },
206
+ friendState,
207
+ ] = await Promise.all([
208
+ supabase
209
+ .from("profileActivities")
210
+ .select("last_activity")
211
+ .eq("uid", user.uid || "")
212
+ .single(),
213
+ supabase
214
+ .from("profileUsernames")
215
+ .select("username,changed_at")
216
+ .eq("profile_id", user.id)
217
+ .order("changed_at", { ascending: false }),
218
+ supabase
219
+ .from("profileFlags")
220
+ .select("id")
221
+ .eq("profile_id", user.id)
222
+ .limit(1),
223
+ supabase
224
+ .from("profileFriends")
225
+ .select("*", { count: "exact", head: true })
226
+ .eq("friend_id", user.id),
227
+ getFriendState(viewerProfileId, user.id),
228
+ ]);
229
+
230
+ //last 30 minutes
231
+ if (activityData && activityData.last_activity) {
232
+ isOnline = Date.now() - activityData.last_activity < 1800000;
233
+ }
168
234
 
169
235
  let position: number | null = null;
170
236
  let countryPosition: number | null = null;
@@ -196,13 +262,13 @@ export async function handler(
196
262
  ? (countryPlayersWithMorePoints || 0) + 1
197
263
  : null;
198
264
  }
199
-
265
+
200
266
  if (user.verificationDeadline < Date.now()) {
201
267
  await supabase
202
268
  .from("profiles")
203
269
  .upsert({
204
- id: user.id,
205
- verified: false,
270
+ id: user.id,
271
+ verified: false,
206
272
  })
207
273
  .select();
208
274
  }
@@ -221,6 +287,8 @@ export async function handler(
221
287
  activity_status: activityStatus,
222
288
  is_online: isOnline,
223
289
  last_active_timestamp: activityData?.last_activity ?? null,
290
+ friend_count: friendCount || 0,
291
+ friend_state: friendState,
224
292
  can_change_flag: !flagHistoryData?.length,
225
293
  next_username_change_at: getNextUsernameChangeAt(latestUsernameChangeAt),
226
294
  previous_usernames: previousUsernames,
package/api/getScore.ts CHANGED
@@ -26,6 +26,7 @@ export const Schema = {
26
26
  username: z.string().optional().nullable(),
27
27
  speed: z.number().optional().nullable(),
28
28
  spin: z.boolean().optional().nullable(),
29
+ replay_url: z.string().optional().nullable(),
29
30
  })
30
31
  .optional(),
31
32
  }),
@@ -80,6 +81,7 @@ export async function handler(
80
81
  username: score.profiles?.username,
81
82
  speed: score.speed,
82
83
  spin: score.spin,
84
+ replay_url: score.replay_url,
83
85
  },
84
86
  });
85
87
  }
@@ -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 > 25000000) {
65
- return NextResponse.json({
66
- error: "Max content length exceeded.",
67
- });
68
- }
69
-
70
- const key = `beatmap-video-${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(["video/mp4", "video/webm", "video/ogg"]);
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 > 25000000) {
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 = `beatmap-video-${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
+ }