mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Add/remove member write API (#2774)
This commit is contained in:
parent
832cd0b8f1
commit
675d609c20
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
39
app/features/api-public/api-action-wrapper.server.ts
Normal file
39
app/features/api-public/api-action-wrapper.server.ts
Normal file
|
|
@ -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<Response> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
52
app/features/api-public/api-auth-middleware.server.ts
Normal file
52
app/features/api-public/api-auth-middleware.server.ts
Normal file
|
|
@ -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<Response>,
|
||||
) => Promise<Response>;
|
||||
|
||||
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();
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, ApiTokenType>();
|
||||
const tokenMap = new Map<string, CachedToken>();
|
||||
|
||||
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<string, CachedToken> = 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
app/features/api-public/routes/api.layout.tsx
Normal file
8
app/features/api-public/routes/api.layout.tsx
Normal file
|
|
@ -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,
|
||||
];
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -501,3 +501,11 @@ type TournamentBracket = {
|
|||
};
|
||||
|
||||
type TournamentBracketData = ValueToArray<DataTypes>;
|
||||
|
||||
/** POST /api/tournament/{id}/teams/{teamId}/add-member */
|
||||
/** POST /api/tournament/{id}/teams/{teamId}/remove-member */
|
||||
|
||||
/** @lintignore */
|
||||
export interface TournamentTeamMemberBody {
|
||||
userId: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
107
app/routes.ts
107
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"),
|
||||
|
|
|
|||
|
|
@ -131,6 +131,22 @@ export function parseParams<T extends z.ZodTypeAny>({
|
|||
return parsed.data;
|
||||
}
|
||||
|
||||
/** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */
|
||||
export async function parseBody<T extends z.ZodTypeAny>({
|
||||
request,
|
||||
schema,
|
||||
}: {
|
||||
request: Request;
|
||||
schema: T;
|
||||
}): Promise<z.infer<T>> {
|
||||
const parsed = schema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function safeParseRequestFormData<T extends z.ZodTypeAny>({
|
||||
request,
|
||||
schema,
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user