diff --git a/app/components/layout/Footer.tsx b/app/components/layout/Footer.tsx index 972bcb0a8..8f75484e5 100644 --- a/app/components/layout/Footer.tsx +++ b/app/components/layout/Footer.tsx @@ -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() { {t("pages.privacy")} {t("pages.contributors")} {t("pages.faq")} + {t("pages.api")}
; isVideoAdder: Generated; isTournamentOrganizer: Generated; + isApiAccesser: Generated; languages: string | null; motionSens: number | null; patronSince: number | null; @@ -913,6 +914,13 @@ export interface UserFriendCode { createdAt: GeneratedAlways; } +export interface ApiToken { + id: GeneratedAlways; + userId: number; + token: string; + createdAt: GeneratedAlways; +} + export interface BanLog { id: GeneratedAlways; userId: number; @@ -1085,6 +1093,7 @@ export type TablesUpdatable = { [P in keyof DB]: Updateable }; export interface DB { AllTeam: Team; AllTeamMember: TeamMember; + ApiToken: ApiToken; Art: Art; ArtTag: ArtTag; ArtUserMetadata: ArtUserMetadata; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index 779fab534..87602ec68 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -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, diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index f554da9cf..4df7bedc0 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -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()), + }), ]); diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index e81b68a00..8fbf7c1f8 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -119,6 +119,7 @@ function AdminActions() { {isStaff ? : null} {isStaff ? : null} {isAdmin ? : null} + {isAdmin ? : null} {isStaff ? : null} {isStaff ? : null} {isAdmin ? : null} @@ -276,6 +277,22 @@ function GiveTournamentOrganizer() { ); } +function GiveApiAccess() { + const fetcher = useFetcher(); + + return ( + +

Give API access

+ +
+ + Grant API access + +
+
+ ); +} + function UpdateFriendCode() { const fetcher = useFetcher(); const id = React.useId(); diff --git a/app/features/api-public/api-public-utils.server.ts b/app/features/api-public/api-public-utils.server.ts index 4bf48d103..b3fdefd01 100644 --- a/app/features/api-public/api-public-utils.server.ts +++ b/app/features/api-public/api-public-utils.server.ts @@ -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 }); } } diff --git a/app/features/api/ApiRepository.server.test.ts b/app/features/api/ApiRepository.server.test.ts new file mode 100644 index 000000000..ea57d064b --- /dev/null +++ b/app/features/api/ApiRepository.server.test.ts @@ -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); + }); +}); diff --git a/app/features/api/ApiRepository.server.ts b/app/features/api/ApiRepository.server.ts new file mode 100644 index 000000000..31ef1a1c5 --- /dev/null +++ b/app/features/api/ApiRepository.server.ts @@ -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); +} diff --git a/app/features/api/actions/api.server.ts b/app/features/api/actions/api.server.ts new file mode 100644 index 000000000..de35b02ce --- /dev/null +++ b/app/features/api/actions/api.server.ts @@ -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; +}; diff --git a/app/features/api/core/perms.test.ts b/app/features/api/core/perms.test.ts new file mode 100644 index 000000000..bc8680a65 --- /dev/null +++ b/app/features/api/core/perms.test.ts @@ -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); + }); +}); diff --git a/app/features/api/core/perms.ts b/app/features/api/core/perms.ts new file mode 100644 index 000000000..0f6be0294 --- /dev/null +++ b/app/features/api/core/perms.ts @@ -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); +} diff --git a/app/features/api/loaders/api.server.ts b/app/features/api/loaders/api.server.ts new file mode 100644 index 000000000..84385f4b5 --- /dev/null +++ b/app/features/api/loaders/api.server.ts @@ -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, + }; +}; diff --git a/app/features/api/routes/api.tsx b/app/features/api/routes/api.tsx new file mode 100644 index 000000000..c70058ce0 --- /dev/null +++ b/app/features/api/routes/api.tsx @@ -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(); + const { t } = useTranslation(["common"]); + + return ( +
+
+

{t("common:api.title")}

+

+ + Generate an API token to access the sendou.ink API. See the + + API documentation + + for available endpoints, usage examples and guidelines to follow. + +

+
+ + {!data.hasAccess ? ( +
+ {t("common:api.noAccess")} +
+ ) : data.apiToken ? ( +
+
+ + }> + {t("common:api.revealButton")} + + } + /> +
+ + + } + > + {t("common:api.regenerate.button")} + + +
+ ) : ( +
+ + {t("common:api.generate")} + +
+ )} +
+ ); +} diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index d8902eb90..73fae3f5c 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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), }; } diff --git a/app/features/user-page/UserRepository.test.ts b/app/features/user-page/UserRepository.test.ts index 1ba385215..06cca37e8 100644 --- a/app/features/user-page/UserRepository.test.ts +++ b/app/features/user-page/UserRepository.test.ts @@ -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"); + }); + }); }); diff --git a/app/modules/permissions/mapper.server.ts b/app/modules/permissions/mapper.server.ts index e4411d65f..2078c140c 100644 --- a/app/modules/permissions/mapper.server.ts +++ b/app/modules/permissions/mapper.server.ts @@ -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; } diff --git a/app/modules/permissions/types.ts b/app/modules/permissions/types.ts index 56043d895..dd492669a 100644 --- a/app/modules/permissions/types.ts +++ b/app/modules/permissions/types.ts @@ -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 diff --git a/app/routes.ts b/app/routes.ts index 54b035558..d2226ff2e 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 8cba32663..73c0f19c2 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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"; diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 1c927f275..94db17c5a 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/docs/dev/api.md b/docs/dev/api.md index e35c9bd20..0dce19ed6 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -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 diff --git a/locales/da/common.json b/locales/da/common.json index d69bf05d3..35ab54ad2 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": "" } diff --git a/locales/de/common.json b/locales/de/common.json index e979d87c5..6bf4fc348 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": "" } diff --git a/locales/en/common.json b/locales/en/common.json index 25050f710..7851e99fd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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 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" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 1bceffe54..6097206a9 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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": "" } diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 929847b61..1630f0a3d 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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": "" } diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index aa94d1c0a..494e1e8ed 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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": "" } diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index f90b2529f..1f4fb2d6f 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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": "" } diff --git a/locales/he/common.json b/locales/he/common.json index de7d4fa48..a5f65637a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "" } diff --git a/locales/it/common.json b/locales/it/common.json index 6a7f6fea7..89569d130 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": "" } diff --git a/locales/ja/common.json b/locales/ja/common.json index d336efa4c..b7660205d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "" } diff --git a/locales/ko/common.json b/locales/ko/common.json index 0230e1b39..2c9e1a10c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "" } diff --git a/locales/nl/common.json b/locales/nl/common.json index 94b398eb1..d949e8230 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": "" } diff --git a/locales/pl/common.json b/locales/pl/common.json index 3eb315203..131e91f8d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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": "" } diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index ea38076bf..3c48ffcc4 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -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": "" } diff --git a/locales/ru/common.json b/locales/ru/common.json index b16529d5e..d103a018d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "" } diff --git a/locales/zh/common.json b/locales/zh/common.json index 57347d72f..b29e617eb 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "" } diff --git a/migrations/105-api-tokens.js b/migrations/105-api-tokens.js new file mode 100644 index 000000000..c35d80453 --- /dev/null +++ b/migrations/105-api-tokens.js @@ -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(); + })(); +}