mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 07:06:14 -05:00
Merge branch 'main' into css-rework-sidenav
This commit is contained in:
@@ -5,6 +5,7 @@ SESSION_SECRET=secret
|
||||
// Auth https://discord.com/developers
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_ADMIN_WEBHOOK_URL=
|
||||
|
||||
// Patreon integration to sync supporter status https://www.patreon.com/portal/registration/register-clients
|
||||
PATREON_ACCESS_TOKEN=
|
||||
@@ -42,5 +43,3 @@ VITE_SHOW_LUTI_NAV_ITEM=false
|
||||
VITE_VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_EMAIL=
|
||||
|
||||
PUBLIC_API_TOKENS=secret,secret2
|
||||
|
||||
@@ -1012,10 +1012,13 @@ export interface UserFriendCode {
|
||||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
export type ApiTokenType = "read" | "write";
|
||||
|
||||
export interface ApiToken {
|
||||
id: GeneratedAlways<number>;
|
||||
userId: number;
|
||||
token: string;
|
||||
type: Generated<ApiTokenType>;
|
||||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { _action, actualNumber, friendCode } from "~/utils/zod";
|
||||
import * as AdminNotifications from "../core/admin-notifications.server";
|
||||
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
@@ -167,6 +168,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
message = "API access granted";
|
||||
break;
|
||||
}
|
||||
case "TEST_ADMIN_NOTIFICATION": {
|
||||
requireRole(user, "ADMIN");
|
||||
|
||||
await AdminNotifications.send("Test notification from admin panel");
|
||||
|
||||
message = "Test notification sent";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
@@ -229,4 +238,7 @@ export const adminActionSchema = z.union([
|
||||
_action: _action("API_ACCESS"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("TEST_ADMIN_NOTIFICATION"),
|
||||
}),
|
||||
]);
|
||||
|
||||
29
app/features/admin/core/admin-notifications.server.ts
Normal file
29
app/features/admin/core/admin-notifications.server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
const DISCORD_ADMIN_WEBHOOK_URL = process.env.DISCORD_ADMIN_WEBHOOK_URL;
|
||||
|
||||
if (!DISCORD_ADMIN_WEBHOOK_URL) {
|
||||
logger.info(
|
||||
"DISCORD_ADMIN_WEBHOOK_URL not set, admin notifications disabled",
|
||||
);
|
||||
}
|
||||
|
||||
export async function send(message: string): Promise<void> {
|
||||
if (!DISCORD_ADMIN_WEBHOOK_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(DISCORD_ADMIN_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to send admin notification: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to send admin notification", error);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,8 @@ function AdminActions() {
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS && <Seed />}
|
||||
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <Seed /> : null}
|
||||
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <TestAdminNotification /> : null}
|
||||
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin ? <Impersonate /> : null}
|
||||
|
||||
{isStaff ? <LinkPlayer /> : null}
|
||||
@@ -459,4 +460,21 @@ function Seed() {
|
||||
);
|
||||
}
|
||||
|
||||
function TestAdminNotification() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post">
|
||||
<h2>Test Admin Notification</h2>
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
_action="TEST_ADMIN_NOTIFICATION"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Send Test
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
export const ErrorBoundary = Catcher;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { ApiTokenType } from "~/db/tables";
|
||||
import * as ApiRepository from "~/features/api/ApiRepository.server";
|
||||
|
||||
async function loadApiTokensCache() {
|
||||
const envTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? [];
|
||||
const dbTokens = await ApiRepository.allApiTokens();
|
||||
return new Set([...envTokens, ...dbTokens]);
|
||||
|
||||
const tokenMap = new Map<string, ApiTokenType>();
|
||||
|
||||
for (const { token, type } of dbTokens) {
|
||||
tokenMap.set(token, type);
|
||||
}
|
||||
|
||||
return tokenMap;
|
||||
}
|
||||
|
||||
let apiTokens = await loadApiTokensCache();
|
||||
@@ -12,12 +19,16 @@ export async function refreshApiTokensCache() {
|
||||
apiTokens = await loadApiTokensCache();
|
||||
}
|
||||
|
||||
export function requireBearerAuth(req: Request) {
|
||||
function extractToken(req: Request) {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
throw new Response("Missing Authorization header", { status: 401 });
|
||||
}
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
return authHeader.replace("Bearer ", "");
|
||||
}
|
||||
|
||||
export function requireBearerAuth(req: Request) {
|
||||
const token = extractToken(req);
|
||||
if (!apiTokens.has(token)) {
|
||||
throw new Response("Invalid token", { status: 401 });
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
"TournamentTeamMember.inGameName",
|
||||
"TournamentTeamMember.isOwner",
|
||||
"TournamentTeamMember.createdAt",
|
||||
"RankedSeedingSkill.mu as rankedOrdinal",
|
||||
"UnrankedSeedingSkill.mu as unrankedOrdinal",
|
||||
"RankedSeedingSkill.ordinal as rankedOrdinal",
|
||||
"UnrankedSeedingSkill.ordinal as unrankedOrdinal",
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
|
||||
@@ -12,15 +12,15 @@ describe("findTokenByUserId", () => {
|
||||
});
|
||||
|
||||
test("returns undefined when user has no token", async () => {
|
||||
const result = await ApiRepository.findTokenByUserId(1);
|
||||
const result = await ApiRepository.findTokenByUserId(1, "read");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("finds existing token for user", async () => {
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
|
||||
const result = await ApiRepository.findTokenByUserId(1);
|
||||
const result = await ApiRepository.findTokenByUserId(1, "read");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.userId).toBe(1);
|
||||
@@ -28,16 +28,30 @@ describe("findTokenByUserId", () => {
|
||||
});
|
||||
|
||||
test("returns correct token for specific user", async () => {
|
||||
const token1 = await ApiRepository.generateToken(1);
|
||||
const token2 = await ApiRepository.generateToken(2);
|
||||
const token1 = await ApiRepository.generateToken(1, "read");
|
||||
const token2 = await ApiRepository.generateToken(2, "read");
|
||||
|
||||
const result1 = await ApiRepository.findTokenByUserId(1);
|
||||
const result2 = await ApiRepository.findTokenByUserId(2);
|
||||
const result1 = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const result2 = await ApiRepository.findTokenByUserId(2, "read");
|
||||
|
||||
expect(result1?.token).toBe(token1.token);
|
||||
expect(result2?.token).toBe(token2.token);
|
||||
expect(result1?.token).not.toBe(result2?.token);
|
||||
});
|
||||
|
||||
test("finds correct token by type", async () => {
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
await ApiRepository.generateToken(1, "write");
|
||||
|
||||
const readResult = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const writeResult = await ApiRepository.findTokenByUserId(1, "write");
|
||||
|
||||
expect(readResult).toBeDefined();
|
||||
expect(writeResult).toBeDefined();
|
||||
expect(readResult?.token).not.toBe(writeResult?.token);
|
||||
expect(readResult?.type).toBe("read");
|
||||
expect(writeResult?.type).toBe("write");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateToken", () => {
|
||||
@@ -50,7 +64,7 @@ describe("generateToken", () => {
|
||||
});
|
||||
|
||||
test("creates new token for user", async () => {
|
||||
const result = await ApiRepository.generateToken(1);
|
||||
const result = await ApiRepository.generateToken(1, "read");
|
||||
|
||||
expect(result.token).toBeDefined();
|
||||
expect(typeof result.token).toBe("string");
|
||||
@@ -58,19 +72,19 @@ describe("generateToken", () => {
|
||||
});
|
||||
|
||||
test("deletes existing token before creating new one", async () => {
|
||||
const firstToken = await ApiRepository.generateToken(1);
|
||||
const secondToken = await ApiRepository.generateToken(1);
|
||||
const firstToken = await ApiRepository.generateToken(1, "read");
|
||||
const secondToken = await ApiRepository.generateToken(1, "read");
|
||||
|
||||
expect(firstToken.token).not.toBe(secondToken.token);
|
||||
|
||||
const storedToken = await ApiRepository.findTokenByUserId(1);
|
||||
const storedToken = await ApiRepository.findTokenByUserId(1, "read");
|
||||
expect(storedToken?.token).toBe(secondToken.token);
|
||||
});
|
||||
|
||||
test("generates unique tokens for different users", async () => {
|
||||
const token1 = await ApiRepository.generateToken(1);
|
||||
const token2 = await ApiRepository.generateToken(2);
|
||||
const token3 = await ApiRepository.generateToken(3);
|
||||
const token1 = await ApiRepository.generateToken(1, "read");
|
||||
const token2 = await ApiRepository.generateToken(2, "read");
|
||||
const token3 = await ApiRepository.generateToken(3, "read");
|
||||
|
||||
expect(token1.token).not.toBe(token2.token);
|
||||
expect(token1.token).not.toBe(token3.token);
|
||||
@@ -78,17 +92,28 @@ describe("generateToken", () => {
|
||||
});
|
||||
|
||||
test("replaces only the specific user's token", async () => {
|
||||
const user1FirstToken = await ApiRepository.generateToken(1);
|
||||
const user2Token = await ApiRepository.generateToken(2);
|
||||
const user1SecondToken = await ApiRepository.generateToken(1);
|
||||
const user1FirstToken = await ApiRepository.generateToken(1, "read");
|
||||
const user2Token = await ApiRepository.generateToken(2, "read");
|
||||
const user1SecondToken = await ApiRepository.generateToken(1, "read");
|
||||
|
||||
const result1 = await ApiRepository.findTokenByUserId(1);
|
||||
const result2 = await ApiRepository.findTokenByUserId(2);
|
||||
const result1 = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const result2 = await ApiRepository.findTokenByUserId(2, "read");
|
||||
|
||||
expect(result1?.token).toBe(user1SecondToken.token);
|
||||
expect(result1?.token).not.toBe(user1FirstToken.token);
|
||||
expect(result2?.token).toBe(user2Token.token);
|
||||
});
|
||||
|
||||
test("allows same user to have both read and write tokens", async () => {
|
||||
const readToken = await ApiRepository.generateToken(1, "read");
|
||||
const writeToken = await ApiRepository.generateToken(1, "write");
|
||||
|
||||
const readResult = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const writeResult = await ApiRepository.findTokenByUserId(1, "write");
|
||||
|
||||
expect(readResult?.token).toBe(readToken.token);
|
||||
expect(writeResult?.token).toBe(writeToken.token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allApiTokens", () => {
|
||||
@@ -106,12 +131,17 @@ describe("allApiTokens", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns array of token strings", async () => {
|
||||
await ApiRepository.generateToken(1);
|
||||
test("returns array of token objects with type", async () => {
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
|
||||
const result = await ApiRepository.allApiTokens();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.every((token) => typeof token === "string")).toBe(true);
|
||||
expect(
|
||||
result.every(
|
||||
(item) =>
|
||||
typeof item.token === "string" && typeof item.type === "string",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +1,43 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "~/db/sql";
|
||||
import type { ApiTokenType } from "~/db/tables";
|
||||
|
||||
const API_TOKEN_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* Finds an API token for the given user ID.
|
||||
* @returns API token record if found, undefined otherwise
|
||||
*/
|
||||
export function findTokenByUserId(userId: number) {
|
||||
/** Finds an API token for the given user ID and type. */
|
||||
export function findTokenByUserId(userId: number, type: ApiTokenType) {
|
||||
return db
|
||||
.selectFrom("ApiToken")
|
||||
.selectAll()
|
||||
.where("userId", "=", userId)
|
||||
.where("type", "=", type)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new API token for the given user.
|
||||
* Deletes any existing token for the user before creating a new one.
|
||||
* @returns Object containing the newly generated token
|
||||
*/
|
||||
export function generateToken(userId: number) {
|
||||
/** Generates a new API token for the given user. Deletes any existing token of the same type before creating a new one. */
|
||||
export function generateToken(userId: number, type: ApiTokenType) {
|
||||
const token = nanoid(API_TOKEN_LENGTH);
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("ApiToken").where("userId", "=", userId).execute();
|
||||
await trx
|
||||
.deleteFrom("ApiToken")
|
||||
.where("userId", "=", userId)
|
||||
.where("type", "=", type)
|
||||
.execute();
|
||||
|
||||
return trx
|
||||
.insertInto("ApiToken")
|
||||
.values({
|
||||
userId,
|
||||
token,
|
||||
type,
|
||||
})
|
||||
.returning("token")
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all valid API tokens from users with API access.
|
||||
* Includes tokens from users with the isApiAccesser flag enabled (includes supporters tier 2+),
|
||||
* or users who are ADMIN, ORGANIZER, or STREAMER members of established tournament organizations.
|
||||
* @returns Array of valid API token strings
|
||||
*/
|
||||
/** Retrieves all valid API tokens and their types from users with API access. */
|
||||
export async function allApiTokens() {
|
||||
const tokens = await db
|
||||
.selectFrom("ApiToken")
|
||||
@@ -57,7 +52,7 @@ export async function allApiTokens() {
|
||||
"TournamentOrganization.id",
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
)
|
||||
.select("ApiToken.token")
|
||||
.select(["ApiToken.token", "ApiToken.type"])
|
||||
// NOTE: permissions logic also exists in checkUserHasApiAccess function
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
@@ -77,5 +72,5 @@ export async function allApiTokens() {
|
||||
.groupBy("ApiToken.token")
|
||||
.execute();
|
||||
|
||||
return tokens.map((row) => row.token);
|
||||
return tokens.map((row) => ({ token: row.token, type: row.type }));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ import { z } from "zod";
|
||||
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseRequestPayload, successToast } from "~/utils/remix.server";
|
||||
import { _action } from "~/utils/zod";
|
||||
import * as ApiRepository from "../ApiRepository.server";
|
||||
import { checkUserHasApiAccess } from "../core/perms";
|
||||
|
||||
const apiActionSchema = z.object({
|
||||
_action: _action("GENERATE"),
|
||||
_action: z.enum(["GENERATE_READ", "GENERATE_WRITE"]),
|
||||
});
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
@@ -24,12 +23,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
switch (data._action) {
|
||||
case "GENERATE": {
|
||||
await ApiRepository.generateToken(user.id);
|
||||
|
||||
case "GENERATE_READ": {
|
||||
await ApiRepository.generateToken(user.id, "read");
|
||||
await refreshApiTokensCache();
|
||||
|
||||
successToast("API token generated successfully");
|
||||
successToast("Read token generated successfully");
|
||||
break;
|
||||
}
|
||||
case "GENERATE_WRITE": {
|
||||
await ApiRepository.generateToken(user.id, "write");
|
||||
await refreshApiTokensCache();
|
||||
successToast("Write token generated successfully");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
||||
test("both functions grant access for isApiAccesser flag", async () => {
|
||||
await AdminRepository.makeApiAccesserByUserId(1);
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
@@ -32,7 +32,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
||||
test("both functions grant access for isTournamentOrganizer flag", async () => {
|
||||
await AdminRepository.makeTournamentOrganizerByUserId(1);
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
@@ -50,7 +50,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
||||
patronTill: add(new Date(), { months: 3 }),
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
@@ -68,7 +68,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
||||
patronTill: add(new Date(), { months: 3 }),
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
@@ -101,7 +101,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
||||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(userId);
|
||||
await ApiRepository.generateToken(userId, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(userId);
|
||||
@@ -131,7 +131,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
||||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(2);
|
||||
await ApiRepository.generateToken(2, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(2);
|
||||
@@ -158,7 +158,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
||||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(2);
|
||||
await ApiRepository.generateToken(2, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(2);
|
||||
|
||||
@@ -10,14 +10,19 @@ export const loader = async () => {
|
||||
if (!hasApiAccess) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
apiToken: null,
|
||||
readToken: null,
|
||||
writeToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
const apiToken = await ApiRepository.findTokenByUserId(user.id);
|
||||
const [readToken, writeToken] = await Promise.all([
|
||||
ApiRepository.findTokenByUserId(user.id, "read"),
|
||||
ApiRepository.findTokenByUserId(user.id, "write"),
|
||||
]);
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
apiToken: apiToken?.token ?? null,
|
||||
readToken: readToken?.token ?? null,
|
||||
writeToken: writeToken?.token ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -44,12 +44,55 @@ export default function ApiPage() {
|
||||
<div>
|
||||
<FormMessage type="info">{t("common:api.noAccess")}</FormMessage>
|
||||
</div>
|
||||
) : data.apiToken ? (
|
||||
) : (
|
||||
<div className="stack lg">
|
||||
<TokenSection
|
||||
token={data.readToken}
|
||||
tokenType="read"
|
||||
generateAction="GENERATE_READ"
|
||||
/>
|
||||
<TokenSection
|
||||
token={data.writeToken}
|
||||
tokenType="write"
|
||||
generateAction="GENERATE_WRITE"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenSection({
|
||||
token,
|
||||
tokenType,
|
||||
generateAction,
|
||||
}: {
|
||||
token: string | null;
|
||||
tokenType: "read" | "write";
|
||||
generateAction: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const isWriteToken = tokenType === "write";
|
||||
const labelKey = isWriteToken
|
||||
? "common:api.writeTokenLabel"
|
||||
: "common:api.readTokenLabel";
|
||||
const descriptionKey = isWriteToken
|
||||
? "common:api.writeTokenDescription"
|
||||
: "common:api.readTokenDescription";
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<h2 className="text-md">{t(labelKey)}</h2>
|
||||
<p className="text-xs text-lighter">{t(descriptionKey)}</p>
|
||||
</div>
|
||||
|
||||
{token ? (
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<label>{t("common:api.tokenLabel")}</label>
|
||||
<CopyToClipboardPopover
|
||||
url={data.apiToken}
|
||||
url={token}
|
||||
trigger={
|
||||
<SendouButton icon={<Eye />}>
|
||||
{t("common:api.revealButton")}
|
||||
@@ -61,24 +104,20 @@ export default function ApiPage() {
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("common:api.regenerate.heading")}
|
||||
submitButtonText={t("common:api.regenerate.confirm")}
|
||||
fields={[["_action", "GENERATE"]]}
|
||||
fields={[["_action", generateAction]]}
|
||||
>
|
||||
<SendouButton
|
||||
className="mx-auto"
|
||||
variant="outlined"
|
||||
icon={<RefreshCcw />}
|
||||
>
|
||||
<SendouButton variant="outlined" icon={<RefreshCcw />}>
|
||||
{t("common:api.regenerate.button")}
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
) : (
|
||||
<form method="post">
|
||||
<SubmitButton _action="GENERATE">
|
||||
<SubmitButton _action={generateAction}>
|
||||
{t("common:api.generate")}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)}
|
||||
</Main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { add } from "date-fns";
|
||||
import { OAuth2Strategy } from "remix-auth-oauth2";
|
||||
import { z } from "zod";
|
||||
import * as AdminNotifications from "~/features/admin/core/admin-notifications.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
let discordApiCooldownUntil: number | null = null;
|
||||
|
||||
const partialDiscordUserSchema = z.object({
|
||||
avatar: z.string().nullish(),
|
||||
discriminator: z.string(),
|
||||
@@ -25,11 +29,28 @@ const discordUserDetailsSchema = z.tuple([
|
||||
partialDiscordUserSchema,
|
||||
partialDiscordConnectionsSchema,
|
||||
]);
|
||||
const discordRateLimitSchema = z.object({
|
||||
retry_after: z.number(),
|
||||
});
|
||||
|
||||
export const DiscordStrategy = () => {
|
||||
const envVars = authEnvVars();
|
||||
|
||||
const jsonIfOk = (res: Response) => {
|
||||
const jsonIfOk = async (res: Response) => {
|
||||
if (res.status === 429) {
|
||||
const body = discordRateLimitSchema.safeParse(await res.clone().json());
|
||||
const retryAfterSeconds = body.success ? body.data.retry_after : 60;
|
||||
discordApiCooldownUntil = add(new Date(), {
|
||||
seconds: retryAfterSeconds,
|
||||
}).getTime();
|
||||
logger.warn(
|
||||
`Discord API rate limited, cooldown for ${retryAfterSeconds}s${body.success ? "" : " (failed to parse retry_after)"}`,
|
||||
);
|
||||
AdminNotifications.send(
|
||||
`Discord API rate limited, cooldown for ${retryAfterSeconds}s`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Auth related call failed with status code ${res.status}`,
|
||||
@@ -40,6 +61,10 @@ export const DiscordStrategy = () => {
|
||||
};
|
||||
|
||||
const fetchProfileViaDiscordApi = (token: string) => {
|
||||
if (discordApiCooldownUntil && Date.now() < discordApiCooldownUntil) {
|
||||
throw new Error("Discord API is rate limited");
|
||||
}
|
||||
|
||||
const authHeader: [string, string] = ["Authorization", `Bearer ${token}`];
|
||||
|
||||
return Promise.all([
|
||||
|
||||
@@ -189,7 +189,7 @@ export async function findById(id: number) {
|
||||
"User.customUrl",
|
||||
"User.country",
|
||||
"User.twitch",
|
||||
"SeedingSkill.mu as ordinal",
|
||||
"SeedingSkill.ordinal",
|
||||
"PlusTier.tier as plusTier",
|
||||
"TournamentTeamMember.isOwner",
|
||||
"TournamentTeamMember.createdAt",
|
||||
|
||||
@@ -77,6 +77,18 @@ describe("actuallyNonEmptyStringOrNull", () => {
|
||||
it("returns null for a string with only tag space emoji", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a string with only Hangul Filler", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("\u3164")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("ㅤㅤㅤ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for other invisible characters", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("\u115F")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\u1160")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\uFEFF")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\u2060")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeString", () => {
|
||||
|
||||
@@ -275,7 +275,19 @@ export function safeJSONParse(value: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_CHARACTERS = ["\u200B", "\u200C", "\u200D", "\u200E", "\u200F", ""];
|
||||
const EMPTY_CHARACTERS = [
|
||||
"\u200B",
|
||||
"\u200C",
|
||||
"\u200D",
|
||||
"\u200E",
|
||||
"\u200F",
|
||||
"",
|
||||
"\u3164",
|
||||
"\u115F",
|
||||
"\u1160",
|
||||
"\uFEFF",
|
||||
"\u2060",
|
||||
];
|
||||
const EMPTY_CHARACTERS_REGEX = new RegExp(EMPTY_CHARACTERS.join("|"), "g");
|
||||
|
||||
const zalgoRe = /%CC%/g;
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
import { expect, seed, test } from "~/utils/playwright";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
|
||||
|
||||
test.describe("Public API", () => {
|
||||
test("OPTIONS preflight request returns 204 with CORS headers", async ({
|
||||
@@ -25,4 +26,34 @@ test.describe("Public API", () => {
|
||||
|
||||
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||
});
|
||||
|
||||
test("creates read API token and calls public endpoint", async ({ page }) => {
|
||||
await seed(page);
|
||||
await impersonate(page);
|
||||
|
||||
await navigate({ page, url: "/api" });
|
||||
|
||||
await page.locator("form").first().getByRole("button").click();
|
||||
await page.waitForURL("/api");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /reveal/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const token = await page.locator("input[readonly]").inputValue();
|
||||
expect(token).toBeTruthy();
|
||||
expect(token.length).toBe(20);
|
||||
|
||||
const response = await page.request.fetch(`/api/user/${ADMIN_ID}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(ADMIN_ID);
|
||||
expect(data.name).toBe("Sendou");
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -336,7 +336,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -336,7 +336,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -345,7 +345,10 @@
|
||||
"api.title": "API Access",
|
||||
"api.description": "Generate an API token to access the sendou.ink API. See the <1>API documentation</1> for available endpoints, usage examples and guidelines to follow.",
|
||||
"api.noAccess": "You do not have access to the API. Access is granted to supporters (Supporter tier or higher) and admins, organizers, or streamers of established tournament organizations.",
|
||||
"api.tokenLabel": "Your API Token",
|
||||
"api.readTokenLabel": "Read Token",
|
||||
"api.readTokenDescription": "Use this token to access read-only API endpoints.",
|
||||
"api.writeTokenLabel": "Write Token",
|
||||
"api.writeTokenDescription": "Use this token to access both read and write API endpoints.",
|
||||
"api.revealButton": "Click to reveal",
|
||||
"api.regenerate.heading": "Regenerating will invalidate your current token. Any applications using the old token will stop working.",
|
||||
"api.regenerate.button": "Regenerate token",
|
||||
|
||||
@@ -338,7 +338,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -338,7 +338,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -338,7 +338,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -338,7 +338,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -337,7 +337,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -338,7 +338,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -332,7 +332,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -332,7 +332,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -336,7 +336,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -339,7 +339,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -338,7 +338,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -339,7 +339,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
@@ -332,7 +332,10 @@
|
||||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
||||
34
migrations/117-api-token-types.js
Normal file
34
migrations/117-api-token-types.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export function up(db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
create table "ApiToken_new" (
|
||||
"id" integer primary key,
|
||||
"token" text not null unique,
|
||||
"userId" integer not null,
|
||||
"type" text not null default 'read',
|
||||
"createdAt" integer default (strftime('%s', 'now')) not null,
|
||||
foreign key ("userId") references "User"("id") on delete cascade
|
||||
) strict
|
||||
`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
insert into "ApiToken_new" ("id", "token", "userId", "type", "createdAt")
|
||||
select "id", "token", "userId", 'read', "createdAt"
|
||||
from "ApiToken"
|
||||
`,
|
||||
).run();
|
||||
|
||||
db.prepare(/* sql */ "drop table ApiToken").run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `alter table "ApiToken_new" rename to "ApiToken"`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `create unique index api_token_user_id_type on "ApiToken"("userId", "type")`,
|
||||
).run();
|
||||
})();
|
||||
}
|
||||
727
package-lock.json
generated
727
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -35,8 +35,8 @@
|
||||
"sync-weapon-params": "tsx scripts/sync-weapon-params.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.971.0",
|
||||
"@aws-sdk/lib-storage": "^3.971.0",
|
||||
"@aws-sdk/client-s3": "^3.974.0",
|
||||
"@aws-sdk/lib-storage": "^3.974.0",
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@react-router/serve": "^7.12.0",
|
||||
"@remix-run/form-data-parser": "^0.14.0",
|
||||
"@tldraw/tldraw": "^3.12.1",
|
||||
"@zumer/snapdom": "^2.0.1",
|
||||
"@zumer/snapdom": "^2.0.2",
|
||||
"aws-sdk": "^2.1693.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -57,7 +57,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"edmonds-blossom-fixed": "^1.0.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next": "^25.8.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"ics": "^3.8.1",
|
||||
@@ -66,7 +66,7 @@
|
||||
"kysely": "^0.28.10",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"markdown-to-jsx": "^9.6.0",
|
||||
"markdown-to-jsx": "^9.6.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"neverthrow": "^8.2.0",
|
||||
"node-cron": "4.2.1",
|
||||
@@ -91,20 +91,20 @@
|
||||
"slugify": "^1.6.6",
|
||||
"swr": "^2.3.8",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@react-router/dev": "^7.12.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react": "^19.2.9",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitest/browser-playwright": "^4.0.17",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"cross-env": "^10.1.0",
|
||||
@@ -120,6 +120,6 @@
|
||||
"vite-plugin-babel": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^6.0.4",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-browser-react": "^2.0.2"
|
||||
"vitest-browser-react": "^2.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user