User generatable API tokens (#2621)

This commit is contained in:
Kalle 2025-11-09 11:07:20 +02:00 committed by GitHub
parent 47e9262d40
commit ff4402d9aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1045 additions and 20 deletions

View File

@ -2,6 +2,7 @@ import { Link } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { usePatrons } from "~/hooks/swr";
import {
API_PAGE,
CONTRIBUTIONS_PAGE,
FAQ_PAGE,
NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL,
@ -28,6 +29,7 @@ export function Footer() {
<Link to={PRIVACY_POLICY_PAGE}>{t("pages.privacy")}</Link>
<Link to={CONTRIBUTIONS_PAGE}>{t("pages.contributors")}</Link>
<Link to={FAQ_PAGE}>{t("pages.faq")}</Link>
<Link to={API_PAGE}>{t("pages.api")}</Link>
</div>
<div className="layout__footer__socials">
<a

View File

@ -859,6 +859,7 @@ export interface User {
isArtist: Generated<DBBoolean | null>;
isVideoAdder: Generated<DBBoolean | null>;
isTournamentOrganizer: Generated<DBBoolean | null>;
isApiAccesser: Generated<DBBoolean | null>;
languages: string | null;
motionSens: number | null;
patronSince: number | null;
@ -913,6 +914,13 @@ export interface UserFriendCode {
createdAt: GeneratedAlways<number>;
}
export interface ApiToken {
id: GeneratedAlways<number>;
userId: number;
token: string;
createdAt: GeneratedAlways<number>;
}
export interface BanLog {
id: GeneratedAlways<number>;
userId: number;
@ -1085,6 +1093,7 @@ export type TablesUpdatable = { [P in keyof DB]: Updateable<DB[P]> };
export interface DB {
AllTeam: Team;
AllTeamMember: TeamMember;
ApiToken: ApiToken;
Art: Art;
ArtTag: ArtTag;
ArtUserMetadata: ArtUserMetadata;

View File

@ -207,6 +207,14 @@ export function makeTournamentOrganizerByUserId(userId: number) {
.execute();
}
export function makeApiAccesserByUserId(userId: number) {
return db
.updateTable("User")
.set({ isApiAccesser: 1 })
.where("User.id", "=", userId)
.execute();
}
export async function linkUserAndPlayer({
userId,
playerId,

View File

@ -159,6 +159,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
message = "Friend code updated";
break;
}
case "API_ACCESS": {
requireRole(user, "ADMIN");
await AdminRepository.makeApiAccesserByUserId(data.user);
message = "API access granted";
break;
}
default: {
assertUnreachable(data);
}
@ -217,4 +225,8 @@ export const adminActionSchema = z.union([
friendCode,
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("API_ACCESS"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
]);

View File

@ -119,6 +119,7 @@ function AdminActions() {
{isStaff ? <GiveArtist /> : null}
{isStaff ? <GiveVideoAdder /> : null}
{isAdmin ? <GiveTournamentOrganizer /> : null}
{isAdmin ? <GiveApiAccess /> : null}
{isStaff ? <UpdateFriendCode /> : null}
{isStaff ? <MigrateUser /> : null}
{isAdmin ? <ForcePatron /> : null}
@ -276,6 +277,22 @@ function GiveTournamentOrganizer() {
);
}
function GiveApiAccess() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Give API access</h2>
<UserSearch label="User" name="user" />
<div className="stack horizontal md">
<SubmitButton type="submit" _action="API_ACCESS" state={fetcher.state}>
Grant API access
</SubmitButton>
</div>
</fetcher.Form>
);
}
function UpdateFriendCode() {
const fetcher = useFetcher();
const id = React.useId();

View File

@ -1,13 +1,25 @@
import { cors } from "remix-utils/cors";
import * as ApiRepository from "~/features/api/ApiRepository.server";
export async function loadApiTokensCache() {
const envTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? [];
const dbTokens = await ApiRepository.allApiTokens();
return new Set([...envTokens, ...dbTokens]);
}
let apiTokens = await loadApiTokensCache();
export async function refreshApiTokensCache() {
apiTokens = await loadApiTokensCache();
}
const apiTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? [];
export function requireBearerAuth(req: Request) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Response("Missing Authorization header", { status: 401 });
}
const token = authHeader.replace("Bearer ", "");
if (!apiTokens.includes(token)) {
if (!apiTokens.has(token)) {
throw new Response("Invalid token", { status: 401 });
}
}

View File

@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as ApiRepository from "./ApiRepository.server";
describe("findTokenByUserId", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("returns undefined when user has no token", async () => {
const result = await ApiRepository.findTokenByUserId(1);
expect(result).toBeUndefined();
});
test("finds existing token for user", async () => {
await ApiRepository.generateToken(1);
const result = await ApiRepository.findTokenByUserId(1);
expect(result).toBeDefined();
expect(result?.userId).toBe(1);
expect(result?.token).toBeDefined();
});
test("returns correct token for specific user", async () => {
const token1 = await ApiRepository.generateToken(1);
const token2 = await ApiRepository.generateToken(2);
const result1 = await ApiRepository.findTokenByUserId(1);
const result2 = await ApiRepository.findTokenByUserId(2);
expect(result1?.token).toBe(token1.token);
expect(result2?.token).toBe(token2.token);
expect(result1?.token).not.toBe(result2?.token);
});
});
describe("generateToken", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("creates new token for user", async () => {
const result = await ApiRepository.generateToken(1);
expect(result.token).toBeDefined();
expect(typeof result.token).toBe("string");
expect(result.token.length).toBeGreaterThan(0);
});
test("deletes existing token before creating new one", async () => {
const firstToken = await ApiRepository.generateToken(1);
const secondToken = await ApiRepository.generateToken(1);
expect(firstToken.token).not.toBe(secondToken.token);
const storedToken = await ApiRepository.findTokenByUserId(1);
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);
expect(token1.token).not.toBe(token2.token);
expect(token1.token).not.toBe(token3.token);
expect(token2.token).not.toBe(token3.token);
});
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 result1 = await ApiRepository.findTokenByUserId(1);
const result2 = await ApiRepository.findTokenByUserId(2);
expect(result1?.token).toBe(user1SecondToken.token);
expect(result1?.token).not.toBe(user1FirstToken.token);
expect(result2?.token).toBe(user2Token.token);
});
});
describe("allApiTokens", () => {
beforeEach(async () => {
await dbInsertUsers(1);
});
afterEach(() => {
dbReset();
});
test("returns empty array when no tokens exist", async () => {
const result = await ApiRepository.allApiTokens();
expect(result).toEqual([]);
});
test("returns array of token strings", async () => {
await ApiRepository.generateToken(1);
const result = await ApiRepository.allApiTokens();
expect(Array.isArray(result)).toBe(true);
expect(result.every((token) => typeof token === "string")).toBe(true);
});
});

View File

@ -0,0 +1,81 @@
import { nanoid } from "nanoid";
import { db } from "~/db/sql";
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) {
return db
.selectFrom("ApiToken")
.selectAll()
.where("userId", "=", userId)
.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) {
const token = nanoid(API_TOKEN_LENGTH);
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("ApiToken").where("userId", "=", userId).execute();
return trx
.insertInto("ApiToken")
.values({
userId,
token,
})
.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
*/
export async function allApiTokens() {
const tokens = await db
.selectFrom("ApiToken")
.innerJoin("User", "User.id", "ApiToken.userId")
.leftJoin(
"TournamentOrganizationMember",
"TournamentOrganizationMember.userId",
"ApiToken.userId",
)
.leftJoin(
"TournamentOrganization",
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select("ApiToken.token")
// NOTE: permissions logic also exists in checkUserHasApiAccess function
.where((eb) =>
eb.or([
eb("User.isApiAccesser", "=", 1),
eb("User.isTournamentOrganizer", "=", 1),
eb("User.patronTier", ">=", 2),
eb.and([
eb("TournamentOrganization.isEstablished", "=", 1),
eb.or([
eb("TournamentOrganizationMember.role", "=", "ADMIN"),
eb("TournamentOrganizationMember.role", "=", "ORGANIZER"),
eb("TournamentOrganizationMember.role", "=", "STREAMER"),
]),
]),
]),
)
.groupBy("ApiToken.token")
.execute();
return tokens.map((row) => row.token);
}

View File

@ -0,0 +1,41 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod/v4";
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"),
});
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
request,
schema: apiActionSchema,
});
const user = await requireUser(request);
const hasApiAccess = await checkUserHasApiAccess(user);
if (!hasApiAccess) {
throw new Response("Forbidden", { status: 403 });
}
switch (data._action) {
case "GENERATE": {
await ApiRepository.generateToken(user.id);
await refreshApiTokensCache();
successToast("API token generated successfully");
break;
}
default: {
throw new Error("Invalid action");
}
}
return null;
};

View File

@ -0,0 +1,170 @@
import { add } from "date-fns";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as ApiRepository from "../ApiRepository.server";
import { checkUserHasApiAccess } from "./perms";
describe("Permission logic consistency between allApiTokens and checkUserHasApiAccess", () => {
beforeEach(async () => {
await dbInsertUsers(10);
});
afterEach(() => {
dbReset();
});
test("both functions grant access for isApiAccesser flag", async () => {
await AdminRepository.makeApiAccesserByUserId(1);
await ApiRepository.generateToken(1);
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions grant access for isTournamentOrganizer flag", async () => {
await AdminRepository.makeTournamentOrganizerByUserId(1);
await ApiRepository.generateToken(1);
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions grant access for patronTier >= 2", async () => {
await AdminRepository.forcePatron({
id: 1,
patronTier: 2,
patronSince: new Date(),
patronTill: add(new Date(), { months: 3 }),
});
await ApiRepository.generateToken(1);
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions deny access for patronTier < 2", async () => {
await AdminRepository.forcePatron({
id: 1,
patronTier: 1,
patronSince: new Date(),
patronTill: add(new Date(), { months: 3 }),
});
await ApiRepository.generateToken(1);
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(1);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
test("both functions grant access for ADMIN/ORGANIZER/STREAMER of established org", async () => {
const org = await TournamentOrganizationRepository.create({
ownerId: 1,
name: "Test Org",
});
await TournamentOrganizationRepository.updateIsEstablished(org.id, true);
const orgData = await TournamentOrganizationRepository.findBySlug(org.slug);
for (const role of ["ADMIN", "ORGANIZER", "STREAMER"] as const) {
const userId = role === "ADMIN" ? 2 : role === "ORGANIZER" ? 3 : 4;
await TournamentOrganizationRepository.update({
id: org.id,
name: orgData!.name,
description: orgData!.description,
socials: orgData!.socials,
members: [{ userId, role, roleDisplayName: null }],
series: [],
badges: [],
});
await ApiRepository.generateToken(userId);
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(userId);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens.length).toBeGreaterThan(0);
expect(hasAccess).toBe(true);
}
});
test("both functions deny access for MEMBER of established org", async () => {
const org = await TournamentOrganizationRepository.create({
ownerId: 1,
name: "Test Org",
});
await TournamentOrganizationRepository.updateIsEstablished(org.id, true);
const orgData = await TournamentOrganizationRepository.findBySlug(org.slug);
await TournamentOrganizationRepository.update({
id: org.id,
name: orgData!.name,
description: orgData!.description,
socials: orgData!.socials,
members: [{ userId: 2, role: "MEMBER", roleDisplayName: null }],
series: [],
badges: [],
});
await ApiRepository.generateToken(2);
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(2);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
test("both functions deny access for ADMIN of non-established org", async () => {
const org = await TournamentOrganizationRepository.create({
ownerId: 1,
name: "Test Org",
});
const orgData = await TournamentOrganizationRepository.findBySlug(org.slug);
await TournamentOrganizationRepository.update({
id: org.id,
name: orgData!.name,
description: orgData!.description,
socials: orgData!.socials,
members: [{ userId: 2, role: "ADMIN", roleDisplayName: null }],
series: [],
badges: [],
});
await ApiRepository.generateToken(2);
const tokens = await ApiRepository.allApiTokens();
const user = await UserRepository.findLeanById(2);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
});

View File

@ -0,0 +1,23 @@
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
/**
* Checks whether a user has permission to access the API.
* A user has API access if they either have the API_ACCESSER role (includes supporters),
* or are an admin/organizer/streamer of an established tournament organization.
*
* @param user - The authenticated user to check permissions for
* @returns True if the user has API access, false otherwise
*/
export async function checkUserHasApiAccess(user: AuthenticatedUser) {
// NOTE: permissions logic also exists in ApiRepository.allApiTokens function
if (user.roles.includes("API_ACCESSER")) {
return true;
}
const orgs = await TournamentOrganizationRepository.findByUserId(user.id, {
roles: ["ADMIN", "ORGANIZER", "STREAMER"],
});
return orgs.some((org) => org.isEstablished);
}

View File

@ -0,0 +1,24 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as ApiRepository from "../ApiRepository.server";
import { checkUserHasApiAccess } from "../core/perms";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const hasApiAccess = await checkUserHasApiAccess(user);
if (!hasApiAccess) {
return {
hasAccess: false,
apiToken: null,
};
}
const apiToken = await ApiRepository.findTokenByUserId(user.id);
return {
hasAccess: true,
apiToken: apiToken?.token ?? null,
};
};

View File

@ -0,0 +1,84 @@
import type { MetaFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { Trans, useTranslation } from "react-i18next";
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { EyeIcon } from "~/components/icons/Eye";
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/api.server";
import { loader } from "../loaders/api.server";
export { loader, action };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "API Access",
location: args.location,
});
};
export default function ApiPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
return (
<Main className="stack lg">
<div>
<h1 className="text-lg">{t("common:api.title")}</h1>
<p className="text-sm">
<Trans t={t} i18nKey="common:api.description">
Generate an API token to access the sendou.ink API. See the
<Link to="/docs/dev/api.md" className="text-theme">
API documentation
</Link>
for available endpoints, usage examples and guidelines to follow.
</Trans>
</p>
</div>
{!data.hasAccess ? (
<div>
<FormMessage type="info">{t("common:api.noAccess")}</FormMessage>
</div>
) : data.apiToken ? (
<div className="stack md">
<div>
<label>{t("common:api.tokenLabel")}</label>
<CopyToClipboardPopover
url={data.apiToken}
trigger={
<SendouButton icon={<EyeIcon />}>
{t("common:api.revealButton")}
</SendouButton>
}
/>
</div>
<FormWithConfirm
dialogHeading={t("common:api.regenerate.heading")}
submitButtonText={t("common:api.regenerate.confirm")}
fields={[["_action", "GENERATE"]]}
>
<SendouButton
className="mx-auto"
variant="outlined"
icon={<RefreshArrowsIcon />}
>
{t("common:api.regenerate.button")}
</SendouButton>
</FormWithConfirm>
</div>
) : (
<form method="post">
<SubmitButton _action="GENERATE">
{t("common:api.generate")}
</SubmitButton>
</form>
)}
</Main>
);
}

View File

@ -323,6 +323,7 @@ export async function findLeanById(id: number) {
"User.isArtist",
"User.isVideoAdder",
"User.isTournamentOrganizer",
"User.isApiAccesser",
"User.patronTier",
"User.languages",
"User.inGameName",
@ -341,7 +342,12 @@ export async function findLeanById(id: number) {
if (!user) return;
return {
...R.omit(user, ["isArtist", "isVideoAdder", "isTournamentOrganizer"]),
...R.omit(user, [
"isArtist",
"isVideoAdder",
"isTournamentOrganizer",
"isApiAccesser",
]),
roles: userRoles(user),
};
}

View File

@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from "vitest";
import { dbReset } from "~/utils/Test";
import * as AdminRepository from "../admin/AdminRepository.server";
import * as UserRepository from "./UserRepository.server";
describe("UserRepository", () => {
@ -60,4 +61,225 @@ describe("UserRepository", () => {
const updatedUser = await UserRepository.findModInfoById(1);
expect(updatedUser?.createdAt).toEqual(createdAt);
});
describe("userRoles", () => {
test("returns empty array for basic user", async () => {
await UserRepository.upsert({
discordId: "79237403620945920",
discordName: "DummyAdmin",
discordAvatar: null,
});
const recentDiscordId = String(
(BigInt(Date.now() - 1420070400000) << 22n) + 1n,
);
const { id } = await UserRepository.upsert({
discordId: recentDiscordId,
discordName: "RegularUser",
discordAvatar: null,
});
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toEqual([]);
});
test("returns ADMIN and STAFF roles for admin user", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945920",
discordName: "AdminUser",
discordAvatar: null,
});
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("ADMIN");
expect(user?.roles).toContain("STAFF");
});
test("returns MINOR_SUPPORT role for patron tier 1", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945921",
discordName: "PatronUser",
discordAvatar: null,
});
const now = new Date();
const oneYearFromNow = new Date(
now.getTime() + 365 * 24 * 60 * 60 * 1000,
);
await AdminRepository.forcePatron({
id,
patronTier: 1,
patronSince: now,
patronTill: oneYearFromNow,
});
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("MINOR_SUPPORT");
expect(user?.roles).not.toContain("SUPPORTER");
});
test("returns SUPPORTER, MINOR_SUPPORT, TOURNAMENT_ADDER, CALENDAR_EVENT_ADDER, and API_ACCESSER roles for patron tier 2", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945921",
discordName: "SupporterUser",
discordAvatar: null,
});
const now = new Date();
const oneYearFromNow = new Date(
now.getTime() + 365 * 24 * 60 * 60 * 1000,
);
await AdminRepository.forcePatron({
id,
patronTier: 2,
patronSince: now,
patronTill: oneYearFromNow,
});
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("SUPPORTER");
expect(user?.roles).toContain("MINOR_SUPPORT");
expect(user?.roles).toContain("TOURNAMENT_ADDER");
expect(user?.roles).toContain("CALENDAR_EVENT_ADDER");
expect(user?.roles).toContain("API_ACCESSER");
});
test("returns PLUS_SERVER_MEMBER role for plus tier user", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945921",
discordName: "PlusUser",
discordAvatar: null,
});
await AdminRepository.replacePlusTiers([{ userId: id, plusTier: 1 }]);
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("PLUS_SERVER_MEMBER");
});
test("returns ARTIST role for artist user", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945921",
discordName: "ArtistUser",
discordAvatar: null,
});
await AdminRepository.makeArtistByUserId(id);
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("ARTIST");
});
test("returns VIDEO_ADDER role for video adder user", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945921",
discordName: "VideoAdderUser",
discordAvatar: null,
});
await AdminRepository.makeVideoAdderByUserId(id);
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("VIDEO_ADDER");
});
test("returns TOURNAMENT_ADDER and API_ACCESSER roles for tournament organizer", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945921",
discordName: "OrganizerUser",
discordAvatar: null,
});
await AdminRepository.makeTournamentOrganizerByUserId(id);
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("TOURNAMENT_ADDER");
expect(user?.roles).toContain("API_ACCESSER");
});
test("returns API_ACCESSER role for api accesser user", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945921",
discordName: "ApiUser",
discordAvatar: null,
});
await AdminRepository.makeApiAccesserByUserId(id);
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("API_ACCESSER");
});
test("returns CALENDAR_EVENT_ADDER role for aged discord account", async () => {
const agedDiscordId = "79237403620945921";
const { id } = await UserRepository.upsert({
discordId: agedDiscordId,
discordName: "AgedUser",
discordAvatar: null,
});
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("CALENDAR_EVENT_ADDER");
});
test("does not return CALENDAR_EVENT_ADDER role for new discord account", async () => {
const recentDiscordId = String(
(BigInt(Date.now() - 1420070400000) << 22n) + 1n,
);
const { id } = await UserRepository.upsert({
discordId: recentDiscordId,
discordName: "NewUser",
discordAvatar: null,
});
const user = await UserRepository.findLeanById(id);
expect(user?.roles).not.toContain("CALENDAR_EVENT_ADDER");
});
test("returns multiple roles for user with multiple privileges", async () => {
const { id } = await UserRepository.upsert({
discordId: "79237403620945920",
discordName: "MultiRoleUser",
discordAvatar: null,
});
const now = new Date();
const oneYearFromNow = new Date(
now.getTime() + 365 * 24 * 60 * 60 * 1000,
);
await AdminRepository.forcePatron({
id,
patronTier: 2,
patronSince: now,
patronTill: oneYearFromNow,
});
await AdminRepository.makeArtistByUserId(id);
await AdminRepository.makeVideoAdderByUserId(id);
await AdminRepository.replacePlusTiers([{ userId: id, plusTier: 2 }]);
const user = await UserRepository.findLeanById(id);
expect(user?.roles).toContain("SUPPORTER");
expect(user?.roles).toContain("MINOR_SUPPORT");
expect(user?.roles).toContain("PLUS_SERVER_MEMBER");
expect(user?.roles).toContain("ARTIST");
expect(user?.roles).toContain("VIDEO_ADDER");
expect(user?.roles).toContain("TOURNAMENT_ADDER");
expect(user?.roles).toContain("CALENDAR_EVENT_ADDER");
expect(user?.roles).toContain("API_ACCESSER");
});
});
});

View File

@ -12,6 +12,7 @@ export function userRoles(
| "isArtist"
| "isTournamentOrganizer"
| "isVideoAdder"
| "isApiAccesser"
| "patronTier"
>,
) {
@ -53,5 +54,9 @@ export function userRoles(
result.push("CALENDAR_EVENT_ADDER");
}
if (user.isTournamentOrganizer || user.isApiAccesser || isSupporter(user)) {
result.push("API_ACCESSER");
}
return result;
}

View File

@ -13,5 +13,6 @@ export type Role =
| "ARTIST"
| "CALENDAR_EVENT_ADDER"
| "TOURNAMENT_ADDER"
| "API_ACCESSER"
| "SUPPORTER" // patrons of "Supporter" tier or higher
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher

View File

@ -207,6 +207,7 @@ export default [
]),
route("/admin", "features/admin/routes/admin.tsx"),
route("/api", "features/api/routes/api.tsx"),
...prefix("/a", [
index("features/articles/routes/a.tsx"),

View File

@ -49,6 +49,8 @@ export const SENDOU_INK_BASE_URL = "https://sendou.ink";
export const BADGES_DOC_LINK =
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/badges.md";
export const API_DOC_LINK =
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/dev/api.md";
export const CREATING_TOURNAMENT_DOC_LINK =
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/tournament-creation.md";
@ -79,6 +81,7 @@ export const twitchUrl = (accountName: string) =>
export const LOG_IN_URL = "/auth";
export const LOG_OUT_URL = "/auth/logout";
export const ADMIN_PAGE = "/admin";
export const API_PAGE = "/api";
export const ARTICLES_MAIN_PAGE = "/a";
export const FAQ_PAGE = "/faq";
export const PRIVACY_POLICY_PAGE = "/privacy-policy";

Binary file not shown.

View File

@ -1,6 +1,10 @@
# API
API for external projects to access sendou.ink data for projects such as streams is available. This API is for reading data, writing is not supported. You will need a token to access the API. Currently access is limited but you can request a token from Sendou.
API for external projects to access sendou.ink data for projects such as streams is available. This API is for reading data, writing is not supported. You will need a token to access the API. If you have permissions (supporters of Supporter tier or higher, or any admin, organizer or streamer of an established organization has access), you can access the https://sendou.ink/api page to generate one.
## Rules
Primarily the API is meant to be used to provide data for tournament stream layouts. Check other usecases with Sendou ahead of time. Cache everything that you can. For anything that is not time sensitive prefer times outside of peak hours (EU/NA evenings). API tokens can be rate limited or disabled if the load causes risks other users using the site.
## Endpoints

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Administratorer",
"pages.api": "",
"pages.articles": "Artikler",
"pages.badges": "Mærker",
"pages.plus": "Plus Server",
@ -306,5 +307,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Artikel",
"pages.badges": "Abzeichen",
"pages.plus": "Plus Server",
@ -306,5 +307,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Admin",
"pages.api": "API",
"pages.articles": "Articles",
"pages.badges": "Badges",
"pages.plus": "Plus Server",
@ -306,5 +307,14 @@
"settings.notifications.browserNotSupported": "Push notifications are not supported on this browser",
"settings.notifications.permissionDenied": "Push notifications were denied. Check your browser settings to re-enable",
"badges.selector.none": "No badges selected",
"badges.selector.select": "Select badge to add"
"badges.selector.select": "Select badge to add",
"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.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",
"api.regenerate.confirm": "Confirm Regenerate",
"api.generate": "Generate Token"
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Administración",
"pages.api": "",
"pages.articles": "Artículos",
"pages.badges": "Insignias",
"pages.plus": "Plus Server",
@ -308,5 +309,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "Ninguna insignia seleccionada",
"badges.selector.select": "Seleccionar insignia añadir"
"badges.selector.select": "Seleccionar insignia añadir",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Administración",
"pages.api": "",
"pages.articles": "Artículos",
"pages.badges": "Insignias",
"pages.plus": "Plus Server",
@ -308,5 +309,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "Ninguna insignia seleccionada",
"badges.selector.select": "Seleccionar insignia añadir"
"badges.selector.select": "Seleccionar insignia añadir",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Articles",
"pages.badges": "Badges",
"pages.plus": "Plus Server",
@ -308,5 +309,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Articles",
"pages.badges": "Badges",
"pages.plus": "Plus Serveur",
@ -308,5 +309,14 @@
"settings.notifications.browserNotSupported": "Les notifications push ne sont pas supporter par cet navigateur",
"settings.notifications.permissionDenied": "Les notifications push ont été refusées. Vérifiez les paramètres de votre navigateur pour les réactiver",
"badges.selector.none": "Aucun badges est sélectionné",
"badges.selector.select": "Selectionner un badge pour l'ajouter"
"badges.selector.select": "Selectionner un badge pour l'ajouter",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "מנהל",
"pages.api": "",
"pages.articles": "כתבות",
"pages.badges": "תגים",
"pages.plus": "שרת פלוס",
@ -307,5 +308,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Articoli",
"pages.badges": "Medaglie",
"pages.plus": "Server Plus",
@ -308,5 +309,14 @@
"settings.notifications.browserNotSupported": "Le notifiche push non sono supportate su questo browser",
"settings.notifications.permissionDenied": "Le notifiche push sono state negate. Controlla le impostazioni del browser per riattivarle",
"badges.selector.none": "Nessuna medaglia selezionata",
"badges.selector.select": "Seleziona medaglia da aggiungere"
"badges.selector.select": "Seleziona medaglia da aggiungere",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "管理",
"pages.api": "",
"pages.articles": "記事一覧",
"pages.badges": "バッジ",
"pages.plus": "Plus Server",
@ -302,5 +303,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "관리자",
"pages.api": "",
"pages.articles": "게시물",
"pages.badges": "배지",
"pages.plus": "Plus Server",
@ -302,5 +303,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "",
"pages.badges": "Badges",
"pages.plus": "Plus Server",
@ -306,5 +307,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Admin",
"pages.api": "",
"pages.articles": "Artykuły",
"pages.badges": "Odznaki",
"pages.plus": "Plus Server",
@ -309,5 +310,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Administrador",
"pages.api": "",
"pages.articles": "Artigos",
"pages.badges": "Insígnias",
"pages.plus": "Servidor Plus",
@ -308,5 +309,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "",
"badges.selector.select": ""
"badges.selector.select": "",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "Админ",
"pages.api": "",
"pages.articles": "Статьи",
"pages.badges": "Значки",
"pages.plus": "Plus Server",
@ -309,5 +310,14 @@
"settings.notifications.browserNotSupported": "Push-уведомления в вашем браузере не поддерживаются",
"settings.notifications.permissionDenied": "Push-уведомления были запрещены. Проверьте настройки вашего бразуера, чтобы включить их обратно",
"badges.selector.none": "Награды не выбраны",
"badges.selector.select": "Выберите награды для добавления"
"badges.selector.select": "Выберите награды для добавления",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -1,5 +1,6 @@
{
"pages.admin": "管理",
"pages.api": "",
"pages.articles": "文章",
"pages.badges": "徽章",
"pages.plus": "Plus Server",
@ -302,5 +303,14 @@
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"badges.selector.none": "没有选择任何徽章",
"badges.selector.select": "选择徽章并添加"
"badges.selector.select": "选择徽章并添加",
"api.title": "",
"api.description": "",
"api.noAccess": "",
"api.tokenLabel": "",
"api.revealButton": "",
"api.regenerate.heading": "",
"api.regenerate.button": "",
"api.regenerate.confirm": "",
"api.generate": ""
}

View File

@ -0,0 +1,23 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `
create table "ApiToken" (
"id" integer primary key,
"token" text not null unique,
"userId" integer not null unique,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("userId") references "User"("id") on delete cascade
) strict
`,
).run();
db.prepare(
/* sql */ `create index api_token_user_id on "ApiToken"("userId")`,
).run();
db.prepare(
/* sql */ `alter table "User" add "isApiAccesser" integer`,
).run();
})();
}