diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 4b7a4f808..3d89ea8dd 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -809,6 +809,12 @@ function patrons() { patronSince: dateToDatabaseTimestamp(faker.date.past()), patronTier: 2, }); + + // Give ORG_ADMIN_TEST_ID API access without patron status + // so they don't get TOURNAMENT_ADDER role + sql + .prepare(`update user set "isApiAccesser" = 1 where id = ?`) + .run(ORG_ADMIN_TEST_ID); } function userIdsInRandomOrder(specialLast = false) { diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts index bcfe5843f..7336dd0d8 100644 --- a/app/features/api-private/routes/seed.ts +++ b/app/features/api-private/routes/seed.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { sql } from "~/db/sql"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; import { SEED_VARIATIONS } from "~/features/api-private/constants"; +import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; @@ -54,6 +55,7 @@ export const action: ActionFunction = async ({ request }) => { await refreshBannedCache(); await refreshSendouQInstance(); await refreshTentativeTiersCache(); + await refreshApiTokensCache(); return Response.json(null); }; diff --git a/app/features/api-public/api-action-wrapper.server.ts b/app/features/api-public/api-action-wrapper.server.ts new file mode 100644 index 000000000..c7e7b9030 --- /dev/null +++ b/app/features/api-public/api-action-wrapper.server.ts @@ -0,0 +1,39 @@ +import type { ActionFunction, ActionFunctionArgs } from "react-router"; + +/** + * Wraps an existing action function for API use. + * Converts redirect-based success/error responses to JSON responses. + * + * The existing actions use: + * - `successToast(message)` which returns `redirect("?__success=message")` + * - `errorToastIfFalsy/errorToastIfErr` which throw `redirect("?__error=message")` + */ +export async function wrapActionForApi( + actionFn: ActionFunction, + args: ActionFunctionArgs, +): Promise { + try { + const response = await actionFn(args); + + if (response instanceof Response && response.status === 302) { + return new Response(null, { status: 200 }); + } + + return response as Response; + } catch (e) { + if (e instanceof Response && e.status === 302) { + const location = e.headers.get("Location") ?? ""; + if (location.includes("__error=")) { + const errorMsg = new URLSearchParams(location.replace("?", "")).get( + "__error", + ); + return new Response(JSON.stringify({ error: errorMsg }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + } + + throw e; + } +} diff --git a/app/features/api-public/api-auth-middleware.server.ts b/app/features/api-public/api-auth-middleware.server.ts new file mode 100644 index 000000000..8ae3b40ba --- /dev/null +++ b/app/features/api-public/api-auth-middleware.server.ts @@ -0,0 +1,52 @@ +import { userAsyncLocalStorage } from "~/features/auth/core/user-context.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { getTokenInfo } from "./api-public-utils.server"; + +type MiddlewareArgs = { + request: Request; + context: unknown; +}; + +type MiddlewareFn = ( + args: MiddlewareArgs, + next: () => Promise, +) => Promise; + +function extractToken(req: Request): string | null { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) return null; + return authHeader.replace("Bearer ", ""); +} + +export const apiAuthMiddleware: MiddlewareFn = async ({ request }, next) => { + if (request.method === "OPTIONS") { + return next(); + } + + const token = extractToken(request); + if (!token) { + return Response.json( + { error: "Missing Authorization header" }, + { status: 401 }, + ); + } + + const tokenInfo = getTokenInfo(token); + if (!tokenInfo) { + return Response.json({ error: "Invalid token" }, { status: 401 }); + } + + if (request.method === "POST" && tokenInfo.type !== "write") { + return Response.json({ error: "Write token required" }, { status: 403 }); + } + + if (request.method === "POST") { + const user = await UserRepository.findLeanById(tokenInfo.userId); + if (!user) { + return Response.json({ error: "User not found" }, { status: 401 }); + } + return userAsyncLocalStorage.run({ user }, () => next()); + } + + return next(); +}; diff --git a/app/features/api-public/api-cors-middleware.server.ts b/app/features/api-public/api-cors-middleware.server.ts index 965761b92..2591cb8bd 100644 --- a/app/features/api-public/api-cors-middleware.server.ts +++ b/app/features/api-public/api-cors-middleware.server.ts @@ -10,19 +10,12 @@ type MiddlewareFn = ( const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", }; export const apiCorsMiddleware: MiddlewareFn = async ({ request }, next) => { - const url = new URL(request.url); - const isApiRoute = url.pathname.startsWith("/api/"); - - if (!isApiRoute) { - return next(); - } - if (request.method === "OPTIONS") { return new Response(null, { status: 204, diff --git a/app/features/api-public/api-public-utils.server.ts b/app/features/api-public/api-public-utils.server.ts index 13b92d734..36a76b440 100644 --- a/app/features/api-public/api-public-utils.server.ts +++ b/app/features/api-public/api-public-utils.server.ts @@ -1,35 +1,26 @@ import type { ApiTokenType } from "~/db/tables"; import * as ApiRepository from "~/features/api/ApiRepository.server"; +type CachedToken = { type: ApiTokenType; userId: number }; + async function loadApiTokensCache() { const dbTokens = await ApiRepository.allApiTokens(); - const tokenMap = new Map(); + const tokenMap = new Map(); - for (const { token, type } of dbTokens) { - tokenMap.set(token, type); + for (const { token, type, userId } of dbTokens) { + tokenMap.set(token, { type, userId }); } return tokenMap; } -let apiTokens = await loadApiTokensCache(); +let apiTokens: Map = await loadApiTokensCache(); + +export function getTokenInfo(token: string): CachedToken | undefined { + return apiTokens.get(token); +} export async function refreshApiTokensCache() { apiTokens = await loadApiTokensCache(); } - -function extractToken(req: Request) { - const authHeader = req.headers.get("Authorization"); - if (!authHeader) { - throw new Response("Missing Authorization header", { status: 401 }); - } - return authHeader.replace("Bearer ", ""); -} - -export function requireBearerAuth(req: Request) { - const token = extractToken(req); - if (!apiTokens.has(token)) { - throw new Response("Invalid token", { status: 401 }); - } -} diff --git a/app/features/api-public/routes/api.layout.tsx b/app/features/api-public/routes/api.layout.tsx new file mode 100644 index 000000000..2fcc724cd --- /dev/null +++ b/app/features/api-public/routes/api.layout.tsx @@ -0,0 +1,8 @@ +import { apiAuthMiddleware } from "../api-auth-middleware.server"; +import { apiCorsMiddleware } from "../api-cors-middleware.server"; +import type { Route } from "./+types/api.layout"; + +export const middleware: Route.MiddlewareFunction[] = [ + apiCorsMiddleware, + apiAuthMiddleware, +]; diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts index 70f8f2419..da6718787 100644 --- a/app/features/api-public/routes/calendar.$year.$week.ts +++ b/app/features/api-public/routes/calendar.$year.$week.ts @@ -7,7 +7,6 @@ import { weekNumberToDate, } from "~/utils/dates"; import { parseParams } from "~/utils/remix.server"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetCalendarWeekResponse } from "../schema"; const paramsSchema = z.object({ @@ -15,9 +14,7 @@ const paramsSchema = z.object({ week: z.coerce.number().int().min(1).max(53), }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { week, year } = parseParams({ params, schema: paramsSchema }); const events = await fetchEventsOfWeek({ diff --git a/app/features/api-public/routes/org.$id.ts b/app/features/api-public/routes/org.$id.ts index 6193e1674..555d489ef 100644 --- a/app/features/api-public/routes/org.$id.ts +++ b/app/features/api-public/routes/org.$id.ts @@ -5,16 +5,13 @@ import { db } from "~/db/sql"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTournamentOrganizationResponse } from "../schema"; const paramsSchema = z.object({ id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id } = parseParams({ params, schema: paramsSchema }); const organization = notFoundIfFalsy( diff --git a/app/features/api-public/routes/sendouq.active-match.$userId.ts b/app/features/api-public/routes/sendouq.active-match.$userId.ts index 6a7331b1d..5aba736f1 100644 --- a/app/features/api-public/routes/sendouq.active-match.$userId.ts +++ b/app/features/api-public/routes/sendouq.active-match.$userId.ts @@ -3,16 +3,13 @@ import { z } from "zod"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetUsersActiveSendouqMatchResponse } from "../schema"; const paramsSchema = z.object({ userId: id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { userId } = parseParams({ params, schema: paramsSchema, diff --git a/app/features/api-public/routes/sendouq.match.$matchId.ts b/app/features/api-public/routes/sendouq.match.$matchId.ts index 9fff6e49b..0f4ac2a2f 100644 --- a/app/features/api-public/routes/sendouq.match.$matchId.ts +++ b/app/features/api-public/routes/sendouq.match.$matchId.ts @@ -4,16 +4,13 @@ import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.s import { i18next } from "~/modules/i18n/i18next.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetSendouqMatchResponse, MapListMap } from "../schema"; const paramsSchema = z.object({ matchId: id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { matchId } = parseParams({ params, schema: paramsSchema, diff --git a/app/features/api-public/routes/team.$id.ts b/app/features/api-public/routes/team.$id.ts index 59c34aea7..610daf8e2 100644 --- a/app/features/api-public/routes/team.$id.ts +++ b/app/features/api-public/routes/team.$id.ts @@ -4,16 +4,13 @@ import { db } from "~/db/sql"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTeamResponse } from "../schema"; const paramsSchema = z.object({ id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id: teamId } = parseParams({ params, schema: paramsSchema }); const team = notFoundIfFalsy( diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts index 26946feb2..2cfbad2f6 100644 --- a/app/features/api-public/routes/tournament-match.$id.ts +++ b/app/features/api-public/routes/tournament-match.$id.ts @@ -10,16 +10,13 @@ import { i18next } from "~/modules/i18n/i18next.server"; import { logger } from "~/utils/logger"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTournamentMatchResponse } from "../schema"; const paramsSchema = z.object({ id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const t = await i18next.getFixedT("en", ["game-misc"]); const { id } = parseParams({ params, diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts index 48bd0c7fb..f5155dcda 100644 --- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts +++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTournamentBracketStandingsResponse } from "../schema"; const paramsSchema = z.object({ @@ -11,9 +10,7 @@ const paramsSchema = z.object({ bidx: z.coerce.number().int(), }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id, bidx } = parseParams({ params, schema: paramsSchema }); const tournament = await tournamentFromDB({ diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts index e79e1a4d5..7e29e8ff4 100644 --- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts +++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts @@ -4,7 +4,6 @@ import type { Bracket } from "~/features/tournament-bracket/core/Bracket"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTournamentBracketResponse } from "../schema"; const paramsSchema = z.object({ @@ -12,9 +11,7 @@ const paramsSchema = z.object({ bidx: z.coerce.number().int(), }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id, bidx } = parseParams({ params, schema: paramsSchema }); const tournament = await tournamentFromDB({ diff --git a/app/features/api-public/routes/tournament.$id.casted.ts b/app/features/api-public/routes/tournament.$id.casted.ts index e53d0278d..65704fdf2 100644 --- a/app/features/api-public/routes/tournament.$id.casted.ts +++ b/app/features/api-public/routes/tournament.$id.casted.ts @@ -3,16 +3,13 @@ import { z } from "zod"; import { db } from "~/db/sql"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetCastedTournamentMatchesResponse } from "../schema"; const paramsSchema = z.object({ id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id } = parseParams({ params, schema: paramsSchema, diff --git a/app/features/api-public/routes/tournament.$id.players.ts b/app/features/api-public/routes/tournament.$id.players.ts index c8aaedd47..f06f4c528 100644 --- a/app/features/api-public/routes/tournament.$id.players.ts +++ b/app/features/api-public/routes/tournament.$id.players.ts @@ -3,16 +3,13 @@ import { z } from "zod"; import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server"; import { parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTournamentPlayersResponse } from "../schema"; const paramsSchema = z.object({ id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id } = parseParams({ params, schema: paramsSchema, diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts new file mode 100644 index 000000000..aa2eb6277 --- /dev/null +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -0,0 +1,42 @@ +import type { ActionFunctionArgs } from "react-router"; +import { z } from "zod"; +import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server"; +import { parseBody, parseParams } from "~/utils/remix.server"; +import { id } from "~/utils/zod"; +import { wrapActionForApi } from "../api-action-wrapper.server"; + +const paramsSchema = z.object({ + id, + teamId: id, +}); + +const bodySchema = z.object({ + userId: id, +}); + +export const action = async (args: ActionFunctionArgs) => { + const { id: tournamentId, teamId } = parseParams({ + params: args.params, + schema: paramsSchema, + }); + const { userId } = await parseBody({ + request: args.request, + schema: bodySchema, + }); + + const internalRequest = new Request(args.request.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + _action: "ADD_MEMBER", + teamId, + userId, + }), + }); + + return wrapActionForApi(adminAction, { + ...args, + params: { id: String(tournamentId) }, + request: internalRequest, + }); +}; diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts new file mode 100644 index 000000000..df162cbff --- /dev/null +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts @@ -0,0 +1,42 @@ +import type { ActionFunctionArgs } from "react-router"; +import { z } from "zod"; +import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server"; +import { parseBody, parseParams } from "~/utils/remix.server"; +import { id } from "~/utils/zod"; +import { wrapActionForApi } from "../api-action-wrapper.server"; + +const paramsSchema = z.object({ + id, + teamId: id, +}); + +const bodySchema = z.object({ + userId: id, +}); + +export const action = async (args: ActionFunctionArgs) => { + const { id: tournamentId, teamId } = parseParams({ + params: args.params, + schema: paramsSchema, + }); + const { userId } = await parseBody({ + request: args.request, + schema: bodySchema, + }); + + const internalRequest = new Request(args.request.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + _action: "REMOVE_MEMBER", + teamId, + memberId: userId, + }), + }); + + return wrapActionForApi(adminAction, { + ...args, + params: { id: String(tournamentId) }, + request: internalRequest, + }); +}; diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index 69533a16c..ad38954d5 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -10,16 +10,13 @@ import { databaseTimestampToDate } from "~/utils/dates"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; import { parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTournamentTeamsResponse } from "../schema"; const paramsSchema = z.object({ id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const t = await i18next.getFixedT("en", ["game-misc"]); const { id } = parseParams({ params, diff --git a/app/features/api-public/routes/tournament.$id.ts b/app/features/api-public/routes/tournament.$id.ts index 3b9718d5c..fc10e80c2 100644 --- a/app/features/api-public/routes/tournament.$id.ts +++ b/app/features/api-public/routes/tournament.$id.ts @@ -5,16 +5,13 @@ import { db } from "~/db/sql"; import { databaseTimestampToDate } from "~/utils/dates"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetTournamentResponse } from "../schema"; const paramsSchema = z.object({ id, }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id } = parseParams({ params, schema: paramsSchema }); const tournament = notFoundIfFalsy( diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index 9f8d78d44..1f7877d4a 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -7,16 +7,13 @@ import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; import { i18next } from "~/modules/i18n/i18next.server"; import { safeNumberParse } from "~/utils/number"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; -import { requireBearerAuth } from "../api-public-utils.server"; import type { GetUserResponse } from "../schema"; const paramsSchema = z.object({ identifier: z.string(), }); -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - requireBearerAuth(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const t = await i18next.getFixedT("en", ["weapons"]); const { identifier } = parseParams({ params, schema: paramsSchema }); diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 73c382e44..144d05853 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -501,3 +501,11 @@ type TournamentBracket = { }; type TournamentBracketData = ValueToArray; + +/** POST /api/tournament/{id}/teams/{teamId}/add-member */ +/** POST /api/tournament/{id}/teams/{teamId}/remove-member */ + +/** @lintignore */ +export interface TournamentTeamMemberBody { + userId: number; +} diff --git a/app/features/api/ApiRepository.server.ts b/app/features/api/ApiRepository.server.ts index 3756be348..8051b5454 100644 --- a/app/features/api/ApiRepository.server.ts +++ b/app/features/api/ApiRepository.server.ts @@ -52,7 +52,7 @@ export async function allApiTokens() { "TournamentOrganization.id", "TournamentOrganizationMember.organizationId", ) - .select(["ApiToken.token", "ApiToken.type"]) + .select(["ApiToken.token", "ApiToken.type", "ApiToken.userId"]) // NOTE: permissions logic also exists in checkUserHasApiAccess function .where((eb) => eb.or([ @@ -72,5 +72,9 @@ export async function allApiTokens() { .groupBy("ApiToken.token") .execute(); - return tokens.map((row) => ({ token: row.token, type: row.type })); + return tokens.map((row) => ({ + token: row.token, + type: row.type, + userId: row.userId, + })); } diff --git a/app/root.tsx b/app/root.tsx index 2343490e1..0a4ada610 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -34,7 +34,6 @@ import { Catcher } from "./components/Catcher"; import { SendouToastRegion, toastQueue } from "./components/elements/Toast"; import { Layout } from "./components/layout"; import { Ramp } from "./components/ramp/Ramp"; -import { apiCorsMiddleware } from "./features/api-public/api-cors-middleware.server"; import { getUser } from "./features/auth/core/user.server"; import { userMiddleware } from "./features/auth/core/user-middleware.server"; import { sessionIdMiddleware } from "./features/session-id/session-id-middleware.server"; @@ -55,7 +54,6 @@ import { isRevalidation, metaTags, type SerializeFrom } from "./utils/remix"; export const middleware: Route.MiddlewareFunction[] = [ sessionIdMiddleware, - apiCorsMiddleware, userMiddleware, ]; diff --git a/app/routes.ts b/app/routes.ts index 3505dd5b5..d046c0b43 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,5 +1,6 @@ import { index, + layout, prefix, type RouteConfig, route, @@ -253,54 +254,64 @@ export default [ route("/seed", "features/api-private/routes/seed.ts"), route("/users", "features/api-private/routes/users.ts"), - ...prefix("/api", [ - route( - "/user/:identifier", - "features/api-public/routes/user.$identifier.ts", - ), - route( - "/user/:identifier/ids", - "features/api-public/routes/user.$identifier.ids.ts", - ), - route( - "/calendar/:year/:week", - "features/api-public/routes/calendar.$year.$week.ts", - ), - route( - "/sendouq/active-match/:userId", - "features/api-public/routes/sendouq.active-match.$userId.ts", - ), - route( - "/sendouq/match/:matchId", - "features/api-public/routes/sendouq.match.$matchId.ts", - ), - route("/tournament/:id", "features/api-public/routes/tournament.$id.ts"), - route( - "/tournament/:id/teams", - "features/api-public/routes/tournament.$id.teams.ts", - ), - route( - "/tournament/:id/players", - "features/api-public/routes/tournament.$id.players.ts", - ), - route( - "/tournament/:id/casted", - "features/api-public/routes/tournament.$id.casted.ts", - ), - route( - "/tournament/:id/brackets/:bidx", - "features/api-public/routes/tournament.$id.brackets.$bidx.ts", - ), - route( - "/tournament/:id/brackets/:bidx/standings", - "features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts", - ), - route( - "/tournament-match/:id", - "features/api-public/routes/tournament-match.$id.ts", - ), - route("/org/:id", "features/api-public/routes/org.$id.ts"), - route("/team/:id", "features/api-public/routes/team.$id.ts"), + layout("features/api-public/routes/api.layout.tsx", [ + ...prefix("/api", [ + route( + "/user/:identifier", + "features/api-public/routes/user.$identifier.ts", + ), + route( + "/user/:identifier/ids", + "features/api-public/routes/user.$identifier.ids.ts", + ), + route( + "/calendar/:year/:week", + "features/api-public/routes/calendar.$year.$week.ts", + ), + route( + "/sendouq/active-match/:userId", + "features/api-public/routes/sendouq.active-match.$userId.ts", + ), + route( + "/sendouq/match/:matchId", + "features/api-public/routes/sendouq.match.$matchId.ts", + ), + route("/tournament/:id", "features/api-public/routes/tournament.$id.ts"), + route( + "/tournament/:id/teams", + "features/api-public/routes/tournament.$id.teams.ts", + ), + route( + "/tournament/:id/players", + "features/api-public/routes/tournament.$id.players.ts", + ), + route( + "/tournament/:id/casted", + "features/api-public/routes/tournament.$id.casted.ts", + ), + route( + "/tournament/:id/brackets/:bidx", + "features/api-public/routes/tournament.$id.brackets.$bidx.ts", + ), + route( + "/tournament/:id/brackets/:bidx/standings", + "features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts", + ), + route( + "/tournament-match/:id", + "features/api-public/routes/tournament-match.$id.ts", + ), + route("/org/:id", "features/api-public/routes/org.$id.ts"), + route("/team/:id", "features/api-public/routes/team.$id.ts"), + route( + "/tournament/:id/teams/:teamId/add-member", + "features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts", + ), + route( + "/tournament/:id/teams/:teamId/remove-member", + "features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts", + ), + ]), ]), route("/short/:customUrl", "features/user-page/routes/short.$customUrl.ts"), diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 4d7b1392a..d1441c7af 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -131,6 +131,22 @@ export function parseParams({ return parsed.data; } +/** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */ +export async function parseBody({ + request, + schema, +}: { + request: Request; + schema: T; +}): Promise> { + const parsed = schema.safeParse(await request.json()); + if (!parsed.success) { + throw new Response(null, { status: 400 }); + } + + return parsed.data; +} + export async function safeParseRequestFormData({ request, schema, diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index 5c71687bb..72e4cf86d 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -1,5 +1,27 @@ +import type { Page } from "@playwright/test"; +import { ORG_ADMIN_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; +import { tournamentTeamPage } from "~/utils/urls"; + +const ITZ_TOURNAMENT_ID = 2; +const ITZ_TEAM_ID = 101; +const USER_NOT_ON_ITZ_TEAM = 100; + +async function generateWriteToken(page: Page): Promise { + await navigate({ page, url: "/api" }); + + // Click the second form (write token) + await page.locator("form").nth(1).getByRole("button").click(); + await page.waitForURL("/api"); + + // Reveal and get the write token + // After generating only the write token, there's just one reveal button (for write) + await page.getByRole("button", { name: /reveal/i }).click(); + const token = await page.locator("input[readonly]").inputValue(); + + return token; +} test.describe("Public API", () => { test("OPTIONS preflight request returns 204 with CORS headers", async ({ @@ -57,3 +79,171 @@ test.describe("Public API", () => { expect(data.name).toBe("Sendou"); }); }); + +test.describe("Public API - Write endpoints", () => { + test("adds member to tournament team via API", async ({ page }) => { + await seed(page); + await impersonate(page, ADMIN_ID); + + const token = await generateWriteToken(page); + + const response = await page.request.fetch( + `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + data: { userId: USER_NOT_ON_ITZ_TEAM }, + }, + ); + + expect(response.status()).toBe(200); + + // Verify in UI that member was added + await navigate({ + page, + url: tournamentTeamPage({ + tournamentId: ITZ_TOURNAMENT_ID, + tournamentTeamId: ITZ_TEAM_ID, + }), + }); + + // User 100 should be visible on the team page + await expect(page.getByTestId("team-member-name")).toHaveCount(5); + }); + + test("removes member from tournament team via API", async ({ page }) => { + await seed(page); + await impersonate(page, ADMIN_ID); + + const token = await generateWriteToken(page); + + // First add the member + await page.request.fetch( + `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + data: { userId: USER_NOT_ON_ITZ_TEAM }, + }, + ); + + // Verify member was added + await navigate({ + page, + url: tournamentTeamPage({ + tournamentId: ITZ_TOURNAMENT_ID, + tournamentTeamId: ITZ_TEAM_ID, + }), + }); + await expect(page.getByTestId("team-member-name")).toHaveCount(5); + + // Remove the member via API + const response = await page.request.fetch( + `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/remove-member`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + data: { userId: USER_NOT_ON_ITZ_TEAM }, + }, + ); + + expect(response.status()).toBe(200); + + // Verify in UI that member was removed + await page.reload(); + await expect(page.getByTestId("team-member-name")).toHaveCount(4); + }); + + test("returns 401 for invalid token", async ({ page }) => { + await seed(page); + + const response = await page.request.fetch( + `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + { + method: "POST", + headers: { + Authorization: "Bearer invalid-token-12345", + "Content-Type": "application/json", + }, + data: { userId: USER_NOT_ON_ITZ_TEAM }, + }, + ); + + expect(response.status()).toBe(401); + const data = await response.json(); + expect(data.error).toBe("Invalid token"); + }); + + test("returns 403 when using read token for write endpoint", async ({ + page, + }) => { + await seed(page); + await impersonate(page, ADMIN_ID); + + await navigate({ page, url: "/api" }); + + // Click the first form (read token) + await page.locator("form").first().getByRole("button").click(); + await page.waitForURL("/api"); + + // Reveal and get the read token + await page + .getByRole("button", { name: /reveal/i }) + .first() + .click(); + const readToken = await page + .locator("input[readonly]") + .first() + .inputValue(); + + const response = await page.request.fetch( + `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + { + method: "POST", + headers: { + Authorization: `Bearer ${readToken}`, + "Content-Type": "application/json", + }, + data: { userId: USER_NOT_ON_ITZ_TEAM }, + }, + ); + + expect(response.status()).toBe(403); + const data = await response.json(); + expect(data.error).toBe("Write token required"); + }); + + test("returns 400 when user is not the organizer of this tournament", async ({ + page, + }) => { + await seed(page); + await impersonate(page, ORG_ADMIN_TEST_ID); + + const token = await generateWriteToken(page); + + const response = await page.request.fetch( + `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + data: { userId: USER_NOT_ON_ITZ_TEAM }, + }, + ); + + expect(response.status()).toBe(400); + const data = await response.json(); + expect(data.error).toBe("Unauthorized"); + }); +}); diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index a1039aa30..561cfb90e 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index e4becfb9a..edf1ef09c 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 7a0024982..089aae25a 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index d3b2c2602..43004361a 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 2fe3c6e05..f835e50bc 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index b60c82143..f587484f5 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index b42013406..b8ef33dfe 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ