Merge branch 'main' into css-rework-sidenav

This commit is contained in:
Kalle
2026-01-25 21:01:21 +02:00
46 changed files with 829 additions and 422 deletions

View File

@@ -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

View File

@@ -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>;
}

View File

@@ -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"),
}),
]);

View 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);
}
}

View File

@@ -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;

View File

@@ -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 });
}

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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 }));
}

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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,
};
};

View File

@@ -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>
);
}

View File

@@ -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([

View File

@@ -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",

View File

@@ -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", () => {

View File

@@ -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;

Binary file not shown.

View File

@@ -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.

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}