rhythia-api 231.0.0 → 234.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/.codex +0 -0
- package/.env +1 -12
- package/README.md +4 -4
- package/api/acceptInvite.ts +1 -1
- package/api/addCollectionMap.ts +1 -1
- package/api/chartPublicStats.ts +1 -1
- package/api/checkQualified.ts +93 -83
- package/api/createBeatmap.ts +53 -62
- package/api/createBeatmapPage.ts +1 -1
- package/api/createClan.ts +1 -1
- package/api/createCollection.ts +1 -1
- package/api/createInvite.ts +1 -1
- package/api/createSupporter.ts +1 -1
- package/api/deleteBeatmapPage.ts +2 -5
- package/api/deleteCollection.ts +1 -1
- package/api/deleteCollectionMap.ts +1 -1
- package/api/editAboutMe.ts +1 -1
- package/api/editClan.ts +1 -1
- package/api/editCollection.ts +1 -2
- package/api/editProfile.ts +1 -1
- package/api/enhancedSearch.ts +113 -0
- package/api/executeAdminOperation.ts +1 -22
- package/api/getAvatarUploadUrl.ts +1 -1
- package/api/getBadgeLeaders.ts +1 -1
- package/api/getBadgedUsers.ts +1 -1
- package/api/getBeatmapComments.ts +1 -1
- package/api/getBeatmapPage.ts +74 -106
- package/api/getBeatmapPageById.ts +70 -109
- package/api/getBeatmapStarRating.ts +1 -1
- package/api/getBeatmaps.ts +1 -1
- package/api/getClan.ts +1 -1
- package/api/getClans.ts +1 -1
- package/api/getCollection.ts +1 -1
- package/api/getCollections.ts +1 -1
- package/api/getInventory.ts +1 -1
- package/api/getLeaderboard.ts +1 -1
- package/api/getMapUploadUrl.ts +2 -2
- package/api/getOnlinePlayers.ts +1 -1
- package/api/getPassToken.ts +1 -1
- package/api/getProfile.ts +51 -31
- package/api/getPublicStats.ts +5 -5
- package/api/getRawStarRating.ts +1 -1
- package/api/getScore.ts +1 -1
- package/api/getStoryBeatmaps.ts +1 -1
- package/api/getTimestamp.ts +1 -1
- package/api/getUserScores.ts +19 -19
- package/api/getVerified.ts +1 -1
- package/api/getVideoUploadUrl.ts +1 -1
- package/api/postBeatmapComment.ts +1 -1
- package/api/qualifyMap.ts +97 -86
- package/api/rankMapsArchive.ts +8 -1
- package/api/searchUsers.ts +1 -1
- package/api/setPasskey.ts +1 -1
- package/api/submitScore.ts +1 -6
- package/api/submitScoreInternal.ts +461 -449
- package/api/updateBeatmapPage.ts +1 -1
- package/api/vetoMap.ts +101 -94
- package/index.ts +173 -120
- package/package.json +7 -12
- package/queries/admin_delete_user.sql +39 -39
- package/queries/admin_exclude_user.sql +21 -21
- package/queries/admin_invalidate_ranked_scores.sql +18 -18
- package/queries/admin_log_action.sql +10 -10
- package/queries/admin_profanity_clear.sql +29 -29
- package/queries/admin_remove_all_scores.sql +29 -29
- package/queries/admin_restrict_user.sql +21 -21
- package/queries/admin_search_users.sql +24 -24
- package/queries/admin_silence_user.sql +21 -21
- package/queries/admin_unban_user.sql +21 -21
- package/queries/enhanced_search.sql +217 -0
- package/queries/get_badge_leaderboard.sql +50 -50
- package/queries/get_clan_leaderboard.sql +68 -68
- package/queries/get_collections_v4.sql +109 -109
- package/queries/get_top_scores_for_beatmap.sql +44 -44
- package/queries/get_top_scores_for_beatmap3.sql +38 -0
- package/queries/get_user_by_email.sql +32 -32
- package/queries/get_user_scores_lastday.sql +47 -47
- package/queries/get_user_scores_reign.sql +31 -31
- package/queries/get_user_scores_top_and_stats.sql +84 -84
- package/queries/grant_special_badges.sql +69 -69
- package/types/database.ts +1288 -1224
- package/utils/beatmapTopScores.ts +84 -0
- package/utils/mapLifecycleWebhook.ts +287 -0
- package/utils/requestGeo.ts +13 -0
- package/utils/requestUtils.ts +127 -127
- package/utils/response.ts +11 -0
- package/worker.ts +189 -0
- package/wrangler.jsonc +10 -0
- package/index.html +0 -3
- package/vercel.json +0 -13
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Database } from "../types/database";
|
|
2
|
+
import { getActiveProfileIdSet } from "./activityStatus";
|
|
3
|
+
import { supabase } from "./supabase";
|
|
4
|
+
|
|
5
|
+
type BeatmapTopScoreRow =
|
|
6
|
+
Database["public"]["Functions"]["get_top_scores_for_beatmap3"]["Returns"][number];
|
|
7
|
+
|
|
8
|
+
export type BeatmapTopScore = {
|
|
9
|
+
id: number;
|
|
10
|
+
awarded_sp: number | null;
|
|
11
|
+
created_at: string;
|
|
12
|
+
misses: number | null;
|
|
13
|
+
mods: Record<string, unknown>;
|
|
14
|
+
passed: boolean | null;
|
|
15
|
+
songId: string | null;
|
|
16
|
+
speed: number | null;
|
|
17
|
+
spin: boolean;
|
|
18
|
+
userId: number | null;
|
|
19
|
+
username: string | null;
|
|
20
|
+
avatar_url: string | null;
|
|
21
|
+
accuracy: number | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type GetBeatmapTopScoresResult = {
|
|
25
|
+
error?: string;
|
|
26
|
+
scores: BeatmapTopScore[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function mapBeatmapTopScore(score: BeatmapTopScoreRow): BeatmapTopScore {
|
|
30
|
+
return {
|
|
31
|
+
id: score.id,
|
|
32
|
+
awarded_sp: score.awarded_sp,
|
|
33
|
+
created_at: score.created_at,
|
|
34
|
+
misses: score.misses,
|
|
35
|
+
mods: (score.mods || {}) as Record<string, unknown>,
|
|
36
|
+
passed: score.passed,
|
|
37
|
+
songId: score.songId,
|
|
38
|
+
speed: score.speed,
|
|
39
|
+
spin: score.spin,
|
|
40
|
+
userId: score.userId,
|
|
41
|
+
username: score.username,
|
|
42
|
+
avatar_url: score.avatar_url,
|
|
43
|
+
accuracy: score.accuracy,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getVisibleTopScoresForBeatmap(
|
|
48
|
+
beatmapHash: string,
|
|
49
|
+
limit: number
|
|
50
|
+
): Promise<GetBeatmapTopScoresResult> {
|
|
51
|
+
if (!beatmapHash) {
|
|
52
|
+
return { scores: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { data: rpcScores, error } = await supabase.rpc(
|
|
56
|
+
"get_top_scores_for_beatmap3",
|
|
57
|
+
{ beatmap_hash: beatmapHash }
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (error) {
|
|
61
|
+
return { error: JSON.stringify(error), scores: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const scoreData = rpcScores || [];
|
|
65
|
+
|
|
66
|
+
const userIds = Array.from(
|
|
67
|
+
new Set(
|
|
68
|
+
scoreData
|
|
69
|
+
.map((score) => score.userId)
|
|
70
|
+
.filter((userId): userId is number => typeof userId === "number")
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
const activeUserIds = await getActiveProfileIdSet(userIds);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
scores: scoreData
|
|
77
|
+
.filter(
|
|
78
|
+
(score) =>
|
|
79
|
+
typeof score.userId === "number" && activeUserIds.has(score.userId)
|
|
80
|
+
)
|
|
81
|
+
.slice(0, limit)
|
|
82
|
+
.map(mapBeatmapTopScore),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { supabase } from "./supabase";
|
|
2
|
+
|
|
3
|
+
type MapLifecycleEvent = "qualified" | "ranked" | "vetoed";
|
|
4
|
+
|
|
5
|
+
const WEBHOOK_COLORS: Record<MapLifecycleEvent, number> = {
|
|
6
|
+
qualified: 0x3498db,
|
|
7
|
+
ranked: 0x2ecc71,
|
|
8
|
+
vetoed: 0xe74c3c,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const WEBHOOK_TITLES: Record<MapLifecycleEvent, string> = {
|
|
12
|
+
qualified: "Map Qualified",
|
|
13
|
+
ranked: "Map Ranked",
|
|
14
|
+
vetoed: "Map Vetoed",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const WEBHOOK_MESSAGES: Record<MapLifecycleEvent, string> = {
|
|
18
|
+
qualified: "A fresh map just reached qualification.",
|
|
19
|
+
ranked: "This map cleared qualification and is now ranked.",
|
|
20
|
+
vetoed: "This map was vetoed and sent back for improvements.",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function clampText(value: string, maxLength: number) {
|
|
24
|
+
const sanitized = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
25
|
+
|
|
26
|
+
if (sanitized.length <= maxLength) {
|
|
27
|
+
return sanitized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (maxLength <= 3) {
|
|
31
|
+
return sanitized.slice(0, maxLength);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return `${sanitized.slice(0, maxLength - 3)}...`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getSafeHttpUrl(value: string | null | undefined) {
|
|
38
|
+
if (!value) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const parsed = new URL(value.trim());
|
|
44
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const serialized = parsed.toString();
|
|
48
|
+
if (serialized.length > 2048) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return serialized;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatLength(milliseconds: number | null | undefined) {
|
|
58
|
+
if (!milliseconds || milliseconds < 0) {
|
|
59
|
+
return "-";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
63
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
64
|
+
const seconds = totalSeconds % 60;
|
|
65
|
+
return `${minutes.toString().padStart(2, "0")}:${seconds
|
|
66
|
+
.toString()
|
|
67
|
+
.padStart(2, "0")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function postMapLifecycleWebhook({
|
|
71
|
+
mapId,
|
|
72
|
+
event,
|
|
73
|
+
vetoReason,
|
|
74
|
+
candidateQualifierUsername,
|
|
75
|
+
}: {
|
|
76
|
+
mapId: number;
|
|
77
|
+
event: MapLifecycleEvent;
|
|
78
|
+
vetoReason?: string;
|
|
79
|
+
candidateQualifierUsername?: string;
|
|
80
|
+
}) {
|
|
81
|
+
const webhookUrl = process.env.WEBHOOK_MSG_DISCORD;
|
|
82
|
+
if (!webhookUrl) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const { data: beatmapPage } = await supabase
|
|
88
|
+
.from("beatmapPages")
|
|
89
|
+
.select(
|
|
90
|
+
`
|
|
91
|
+
id,
|
|
92
|
+
owner,
|
|
93
|
+
title,
|
|
94
|
+
tags,
|
|
95
|
+
status,
|
|
96
|
+
qualified,
|
|
97
|
+
qualifiedAt,
|
|
98
|
+
beatmaps (
|
|
99
|
+
title,
|
|
100
|
+
starRating,
|
|
101
|
+
length,
|
|
102
|
+
difficulty,
|
|
103
|
+
noteCount,
|
|
104
|
+
image,
|
|
105
|
+
imageLarge
|
|
106
|
+
),
|
|
107
|
+
profiles (
|
|
108
|
+
id,
|
|
109
|
+
username,
|
|
110
|
+
avatar_url
|
|
111
|
+
)
|
|
112
|
+
`
|
|
113
|
+
)
|
|
114
|
+
.eq("id", mapId)
|
|
115
|
+
.single();
|
|
116
|
+
|
|
117
|
+
if (!beatmapPage) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const beatmapData = (beatmapPage as any).beatmaps;
|
|
122
|
+
const profileData = (beatmapPage as any).profiles;
|
|
123
|
+
const mapTitle =
|
|
124
|
+
beatmapData?.title || beatmapPage.title || `Beatmap Page #${mapId}`;
|
|
125
|
+
const creatorName = profileData?.username || "Unknown";
|
|
126
|
+
const creatorId = beatmapPage.owner || profileData?.id || 0;
|
|
127
|
+
const rawImage =
|
|
128
|
+
beatmapData?.imageLarge || beatmapData?.image || "https://www.rhythia.com/unkimg.png";
|
|
129
|
+
const mapImage = rawImage.includes("backfill")
|
|
130
|
+
? "https://www.rhythia.com/unkimg.png"
|
|
131
|
+
: rawImage;
|
|
132
|
+
const safeMapImageUrl = getSafeHttpUrl(mapImage);
|
|
133
|
+
const safeAvatarUrl = getSafeHttpUrl(profileData?.avatar_url);
|
|
134
|
+
|
|
135
|
+
const fields: Array<{ name: string; value: string; inline?: boolean }> = [
|
|
136
|
+
{
|
|
137
|
+
name: "Map ID",
|
|
138
|
+
value: clampText(`${beatmapPage.id}`, 1024),
|
|
139
|
+
inline: true,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "Creator",
|
|
143
|
+
value: clampText(creatorName, 1024),
|
|
144
|
+
inline: true,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: "Stars",
|
|
148
|
+
value: clampText(
|
|
149
|
+
beatmapData?.starRating !== null && beatmapData?.starRating !== undefined
|
|
150
|
+
? `${Math.round(beatmapData.starRating * 100) / 100}*`
|
|
151
|
+
: "-",
|
|
152
|
+
1024
|
|
153
|
+
),
|
|
154
|
+
inline: true,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "Length",
|
|
158
|
+
value: clampText(formatLength(beatmapData?.length), 1024),
|
|
159
|
+
inline: true,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "Notes",
|
|
163
|
+
value: clampText(
|
|
164
|
+
beatmapData?.noteCount !== null && beatmapData?.noteCount !== undefined
|
|
165
|
+
? `${beatmapData.noteCount}`
|
|
166
|
+
: "-",
|
|
167
|
+
1024
|
|
168
|
+
),
|
|
169
|
+
inline: true,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "Tags",
|
|
173
|
+
value: clampText(beatmapPage.tags || "-", 1024),
|
|
174
|
+
inline: false,
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
if (event === "qualified" && candidateQualifierUsername) {
|
|
179
|
+
fields.splice(2, 0, {
|
|
180
|
+
name: "Qualified By",
|
|
181
|
+
value: clampText(`${candidateQualifierUsername} (Candidate)`, 1024),
|
|
182
|
+
inline: true,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (event === "vetoed") {
|
|
187
|
+
fields.push({
|
|
188
|
+
name: "Veto Reason",
|
|
189
|
+
value: clampText(vetoReason || "No reason provided", 1024),
|
|
190
|
+
inline: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const embed: Record<string, any> = {
|
|
195
|
+
title: clampText(`${WEBHOOK_TITLES[event]}: ${mapTitle}`, 256),
|
|
196
|
+
url: `https://www.rhythia.com/maps/${beatmapPage.id}`,
|
|
197
|
+
description: clampText(WEBHOOK_MESSAGES[event], 4096),
|
|
198
|
+
color: WEBHOOK_COLORS[event],
|
|
199
|
+
fields: fields.map((field) => ({
|
|
200
|
+
...field,
|
|
201
|
+
name: clampText(field.name || "-", 256),
|
|
202
|
+
value: clampText(field.value || "-", 1024),
|
|
203
|
+
})),
|
|
204
|
+
author: {
|
|
205
|
+
name: clampText(creatorName, 256),
|
|
206
|
+
url: `https://www.rhythia.com/player/${creatorId}`,
|
|
207
|
+
icon_url: safeAvatarUrl || "https://www.rhythia.com/unkimg.png",
|
|
208
|
+
},
|
|
209
|
+
footer: {
|
|
210
|
+
text: clampText(
|
|
211
|
+
`Status: ${beatmapPage.status || "-"} | ${new Date().toUTCString()}`,
|
|
212
|
+
2048
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (safeMapImageUrl) {
|
|
218
|
+
embed.thumbnail = {
|
|
219
|
+
url: safeMapImageUrl,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const payload = {
|
|
224
|
+
content: clampText(WEBHOOK_MESSAGES[event], 2000),
|
|
225
|
+
embeds: [embed],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
let response = await fetch(webhookUrl, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: {
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify(payload),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
!response.ok &&
|
|
238
|
+
response.status === 400 &&
|
|
239
|
+
(payload.embeds?.[0]?.image?.url || payload.embeds?.[0]?.thumbnail?.url)
|
|
240
|
+
) {
|
|
241
|
+
// Most common Discord embed 400 here is a bad media URL. Retry without media.
|
|
242
|
+
const retryPayload = {
|
|
243
|
+
...payload,
|
|
244
|
+
embeds: payload.embeds.map((embed: any) => {
|
|
245
|
+
const clone = { ...embed };
|
|
246
|
+
delete clone.image;
|
|
247
|
+
delete clone.thumbnail;
|
|
248
|
+
return clone;
|
|
249
|
+
}),
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
response = await fetch(webhookUrl, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: {
|
|
255
|
+
"Content-Type": "application/json",
|
|
256
|
+
},
|
|
257
|
+
body: JSON.stringify(retryPayload),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
const responseBody = await response.text();
|
|
263
|
+
console.log("Discord webhook failed", {
|
|
264
|
+
event,
|
|
265
|
+
mapId,
|
|
266
|
+
status: response.status,
|
|
267
|
+
statusText: response.statusText,
|
|
268
|
+
responseBody: clampText(responseBody || "-", 4000),
|
|
269
|
+
payloadPreview: {
|
|
270
|
+
content: payload.content,
|
|
271
|
+
title: payload.embeds?.[0]?.title,
|
|
272
|
+
fields: payload.embeds?.[0]?.fields?.map((field: any) => ({
|
|
273
|
+
name: field.name,
|
|
274
|
+
value: clampText(field.value || "-", 120),
|
|
275
|
+
})),
|
|
276
|
+
imageUrl: payload.embeds?.[0]?.image?.url || null,
|
|
277
|
+
thumbnailUrl: payload.embeds?.[0]?.thumbnail?.url || null,
|
|
278
|
+
authorIconUrl: payload.embeds?.[0]?.author?.icon_url || null,
|
|
279
|
+
hasImage: Boolean(payload.embeds?.[0]?.image?.url),
|
|
280
|
+
hasThumbnail: Boolean(payload.embeds?.[0]?.thumbnail?.url),
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.log("Failed to post map lifecycle webhook", error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type RequestWithCf = Request & {
|
|
2
|
+
cf?: {
|
|
3
|
+
country?: string | null;
|
|
4
|
+
};
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function geolocation(request: Request) {
|
|
8
|
+
const { cf } = request as RequestWithCf;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
country: cf?.country || request.headers.get("cf-ipcountry") || undefined,
|
|
12
|
+
};
|
|
13
|
+
}
|
package/utils/requestUtils.ts
CHANGED
|
@@ -1,127 +1,127 @@
|
|
|
1
|
-
import { NextResponse } from "
|
|
2
|
-
import { ZodObject } from "zod";
|
|
3
|
-
import { getUserBySession } from "./getUserBySession";
|
|
4
|
-
import { supabase } from "./supabase";
|
|
5
|
-
|
|
6
|
-
const SENSITIVE_LOG_KEYS = new Set([
|
|
7
|
-
"session",
|
|
8
|
-
"replayBytes",
|
|
9
|
-
"token",
|
|
10
|
-
"secret",
|
|
11
|
-
"passkey",
|
|
12
|
-
"passKey",
|
|
13
|
-
]);
|
|
14
|
-
const LONG_LOG_STRING_THRESHOLD = 256;
|
|
15
|
-
|
|
16
|
-
function sanitizeForLog(
|
|
17
|
-
value: unknown,
|
|
18
|
-
key?: string
|
|
19
|
-
):
|
|
20
|
-
| string
|
|
21
|
-
| number
|
|
22
|
-
| boolean
|
|
23
|
-
| null
|
|
24
|
-
| undefined
|
|
25
|
-
| Record<string, unknown>
|
|
26
|
-
| unknown[] {
|
|
27
|
-
const normalizedKey = (key || "").toLowerCase();
|
|
28
|
-
if (
|
|
29
|
-
SENSITIVE_LOG_KEYS.has(key || "") ||
|
|
30
|
-
SENSITIVE_LOG_KEYS.has(normalizedKey)
|
|
31
|
-
) {
|
|
32
|
-
if (value === null || value === undefined) {
|
|
33
|
-
return value as null | undefined;
|
|
34
|
-
}
|
|
35
|
-
return "<Long>";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (typeof value === "string") {
|
|
39
|
-
return value.length > LONG_LOG_STRING_THRESHOLD ? "<Long>" : value;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (Array.isArray(value)) {
|
|
43
|
-
return value.map((item) => sanitizeForLog(item));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (value && typeof value === "object") {
|
|
47
|
-
const sanitizedObject: Record<string, unknown> = {};
|
|
48
|
-
Object.entries(value as Record<string, unknown>).forEach(
|
|
49
|
-
([entryKey, entryValue]) => {
|
|
50
|
-
sanitizedObject[entryKey] = sanitizeForLog(entryValue, entryKey);
|
|
51
|
-
}
|
|
52
|
-
);
|
|
53
|
-
return sanitizedObject;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return value as string | number | boolean | null | undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface Props<
|
|
60
|
-
K = (...args: any[]) => Promise<NextResponse<any>>,
|
|
61
|
-
T = ZodObject<any>,
|
|
62
|
-
> {
|
|
63
|
-
request: Request;
|
|
64
|
-
schema: { input: T; output: T };
|
|
65
|
-
authorization?: Function;
|
|
66
|
-
activity: K;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function protectedApi({
|
|
70
|
-
request,
|
|
71
|
-
schema,
|
|
72
|
-
authorization,
|
|
73
|
-
activity,
|
|
74
|
-
}: Props) {
|
|
75
|
-
try {
|
|
76
|
-
const toParse = await request.json();
|
|
77
|
-
const data = schema.input.parse(toParse);
|
|
78
|
-
|
|
79
|
-
console.log("Request payload:", sanitizeForLog(data));
|
|
80
|
-
|
|
81
|
-
setActivity(data);
|
|
82
|
-
if (authorization) {
|
|
83
|
-
const authorizationResponse = await authorization(data);
|
|
84
|
-
if (authorizationResponse) {
|
|
85
|
-
return authorizationResponse;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return await activity(data, request);
|
|
89
|
-
} catch (error) {
|
|
90
|
-
console.log(`Couldn't parse`, error.toString());
|
|
91
|
-
return NextResponse.json({ error: error.toString() }, { status: 400 });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export async function setActivity(data: Record<string, any>) {
|
|
96
|
-
if (data.session) {
|
|
97
|
-
const user = (await supabase.auth.getUser(data.session)).data.user;
|
|
98
|
-
if (user) {
|
|
99
|
-
await supabase.from("profileActivities").upsert({
|
|
100
|
-
uid: user.id,
|
|
101
|
-
last_activity: Date.now(),
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export async function validUser(data) {
|
|
108
|
-
if (!data.session) {
|
|
109
|
-
return NextResponse.json(
|
|
110
|
-
{
|
|
111
|
-
error: "Session is missing",
|
|
112
|
-
},
|
|
113
|
-
{ status: 501 }
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const user = await getUserBySession(data.session);
|
|
118
|
-
if (!user) {
|
|
119
|
-
console.log("Invalid user session");
|
|
120
|
-
return NextResponse.json(
|
|
121
|
-
{
|
|
122
|
-
error: "Invalid user session",
|
|
123
|
-
},
|
|
124
|
-
{ status: 401 }
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
1
|
+
import { NextResponse } from "./response";
|
|
2
|
+
import { ZodObject } from "zod";
|
|
3
|
+
import { getUserBySession } from "./getUserBySession";
|
|
4
|
+
import { supabase } from "./supabase";
|
|
5
|
+
|
|
6
|
+
const SENSITIVE_LOG_KEYS = new Set([
|
|
7
|
+
"session",
|
|
8
|
+
"replayBytes",
|
|
9
|
+
"token",
|
|
10
|
+
"secret",
|
|
11
|
+
"passkey",
|
|
12
|
+
"passKey",
|
|
13
|
+
]);
|
|
14
|
+
const LONG_LOG_STRING_THRESHOLD = 256;
|
|
15
|
+
|
|
16
|
+
function sanitizeForLog(
|
|
17
|
+
value: unknown,
|
|
18
|
+
key?: string
|
|
19
|
+
):
|
|
20
|
+
| string
|
|
21
|
+
| number
|
|
22
|
+
| boolean
|
|
23
|
+
| null
|
|
24
|
+
| undefined
|
|
25
|
+
| Record<string, unknown>
|
|
26
|
+
| unknown[] {
|
|
27
|
+
const normalizedKey = (key || "").toLowerCase();
|
|
28
|
+
if (
|
|
29
|
+
SENSITIVE_LOG_KEYS.has(key || "") ||
|
|
30
|
+
SENSITIVE_LOG_KEYS.has(normalizedKey)
|
|
31
|
+
) {
|
|
32
|
+
if (value === null || value === undefined) {
|
|
33
|
+
return value as null | undefined;
|
|
34
|
+
}
|
|
35
|
+
return "<Long>";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof value === "string") {
|
|
39
|
+
return value.length > LONG_LOG_STRING_THRESHOLD ? "<Long>" : value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return value.map((item) => sanitizeForLog(item));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (value && typeof value === "object") {
|
|
47
|
+
const sanitizedObject: Record<string, unknown> = {};
|
|
48
|
+
Object.entries(value as Record<string, unknown>).forEach(
|
|
49
|
+
([entryKey, entryValue]) => {
|
|
50
|
+
sanitizedObject[entryKey] = sanitizeForLog(entryValue, entryKey);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
return sanitizedObject;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return value as string | number | boolean | null | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Props<
|
|
60
|
+
K = (...args: any[]) => Promise<NextResponse<any>>,
|
|
61
|
+
T = ZodObject<any>,
|
|
62
|
+
> {
|
|
63
|
+
request: Request;
|
|
64
|
+
schema: { input: T; output: T };
|
|
65
|
+
authorization?: Function;
|
|
66
|
+
activity: K;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function protectedApi({
|
|
70
|
+
request,
|
|
71
|
+
schema,
|
|
72
|
+
authorization,
|
|
73
|
+
activity,
|
|
74
|
+
}: Props) {
|
|
75
|
+
try {
|
|
76
|
+
const toParse = await request.json();
|
|
77
|
+
const data = schema.input.parse(toParse);
|
|
78
|
+
|
|
79
|
+
console.log("Request payload:", sanitizeForLog(data));
|
|
80
|
+
|
|
81
|
+
setActivity(data);
|
|
82
|
+
if (authorization) {
|
|
83
|
+
const authorizationResponse = await authorization(data);
|
|
84
|
+
if (authorizationResponse) {
|
|
85
|
+
return authorizationResponse;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return await activity(data, request);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.log(`Couldn't parse`, error.toString());
|
|
91
|
+
return NextResponse.json({ error: error.toString() }, { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function setActivity(data: Record<string, any>) {
|
|
96
|
+
if (data.session) {
|
|
97
|
+
const user = (await supabase.auth.getUser(data.session)).data.user;
|
|
98
|
+
if (user) {
|
|
99
|
+
await supabase.from("profileActivities").upsert({
|
|
100
|
+
uid: user.id,
|
|
101
|
+
last_activity: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function validUser(data) {
|
|
108
|
+
if (!data.session) {
|
|
109
|
+
return NextResponse.json(
|
|
110
|
+
{
|
|
111
|
+
error: "Session is missing",
|
|
112
|
+
},
|
|
113
|
+
{ status: 501 }
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const user = await getUserBySession(data.session);
|
|
118
|
+
if (!user) {
|
|
119
|
+
console.log("Invalid user session");
|
|
120
|
+
return NextResponse.json(
|
|
121
|
+
{
|
|
122
|
+
error: "Invalid user session",
|
|
123
|
+
},
|
|
124
|
+
{ status: 401 }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class NextResponse<Body = unknown> extends Response {
|
|
2
|
+
static json<Body>(body: Body, init: ResponseInit = {}) {
|
|
3
|
+
const headers = new Headers(init.headers);
|
|
4
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
5
|
+
|
|
6
|
+
return new NextResponse<Body>(JSON.stringify(body), {
|
|
7
|
+
...init,
|
|
8
|
+
headers,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|