Fix HTTP OPTIONS request to /api/ (#2697)

This commit is contained in:
Kalle 2026-01-04 22:03:21 +02:00 committed by GitHub
parent 650bd0028e
commit fcefe17430
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 107 additions and 182 deletions

View File

@ -0,0 +1,45 @@
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, 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,
headers: CORS_HEADERS,
});
}
const response = await next();
const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(CORS_HEADERS)) {
newHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
};

View File

@ -1,4 +1,3 @@
import { cors } from "remix-utils/cors";
import * as ApiRepository from "~/features/api/ApiRepository.server";
async function loadApiTokensCache() {
@ -23,12 +22,3 @@ export function requireBearerAuth(req: Request) {
throw new Response("Invalid token", { status: 401 });
}
}
export async function handleOptionsRequest(req: Request) {
if (req.method === "OPTIONS") {
throw await cors(req, new Response("OK", { status: 204 }), {
origin: "*",
credentials: true,
});
}
}

View File

@ -1,5 +1,4 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import {
@ -8,10 +7,7 @@ import {
weekNumberToDate,
} from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetCalendarWeekResponse } from "../schema";
const paramsSchema = z.object({
@ -20,7 +16,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { week, year } = parseParams({ params, schema: paramsSchema });
@ -39,7 +34,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
: null,
}));
return await cors(request, Response.json(result));
return Response.json(result);
};
function fetchEventsOfWeek(args: { week: number; year: number }) {

View File

@ -1,15 +1,11 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentOrganizationResponse } from "../schema";
const paramsSchema = z.object({
@ -17,7 +13,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({ params, schema: paramsSchema });
@ -75,5 +70,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetUsersActiveSendouqMatchResponse } from "../schema";
const paramsSchema = z.object({
@ -15,7 +11,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { userId } = parseParams({
@ -29,5 +24,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
matchId: current?.matchId ?? null,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,14 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetSendouqMatchResponse, MapListMap } from "../schema";
const paramsSchema = z.object({
@ -16,7 +12,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { matchId } = parseParams({
@ -84,5 +79,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
},
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,14 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTeamResponse } from "../schema";
const paramsSchema = z.object({
@ -16,7 +12,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id: teamId } = parseParams({ params, schema: paramsSchema });
@ -48,5 +43,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
teamPageUrl: `https://sendou.ink/t/${team.customUrl}`,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,6 +1,5 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
@ -11,10 +10,7 @@ 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 {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentMatchResponse } from "../schema";
const paramsSchema = z.object({
@ -22,7 +18,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const t = await i18next.getFixedT("en", ["game-misc"]);
@ -181,5 +176,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
roundName: roundNameWithoutMatchIdentifier ?? null,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
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 {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentBracketStandingsResponse } from "../schema";
const paramsSchema = z.object({
@ -16,7 +12,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id, bidx } = parseParams({ params, schema: paramsSchema });
@ -37,5 +32,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,14 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
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 {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentBracketResponse } from "../schema";
const paramsSchema = z.object({
@ -17,7 +13,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id, bidx } = parseParams({ params, schema: paramsSchema });
@ -51,7 +46,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
},
};
return await cors(request, Response.json(result));
return Response.json(result);
};
function teams(bracket: Bracket) {

View File

@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetCastedTournamentMatchesResponse } from "../schema";
const paramsSchema = z.object({
@ -15,7 +11,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({
@ -47,5 +42,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})) ?? [],
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
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 {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentPlayersResponse } from "../schema";
const paramsSchema = z.object({
@ -15,7 +11,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({
@ -26,5 +21,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const participants: GetTournamentPlayersResponse =
await TournamentMatchRepository.userParticipationByTournamentId(id);
return cors(request, Response.json(participants));
return Response.json(participants);
};

View File

@ -1,6 +1,5 @@
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { ordinalToSp } from "~/features/mmr/mmr-utils";
@ -11,10 +10,7 @@ import { databaseTimestampToDate } from "~/utils/dates";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentTeamsResponse } from "../schema";
const paramsSchema = z.object({
@ -22,7 +18,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const t = await i18next.getFixedT("en", ["game-misc"]);
@ -170,7 +165,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
};
});
return await cors(request, Response.json(result));
return Response.json(result);
};
function toSeedingPowerSP(ordinals: (number | null)[]) {

View File

@ -1,15 +1,11 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentResponse } from "../schema";
const paramsSchema = z.object({
@ -17,7 +13,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({ params, schema: paramsSchema });
@ -84,5 +79,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
isFinalized: Boolean(tournament.isFinalized),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,18 +1,14 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod/v4";
import { identifierToUserIdQuery } from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { handleOptionsRequest } from "../api-public-utils.server";
import type { GetUserIdsResponse } from "../schema";
const paramsSchema = z.object({
identifier: z.string(),
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { identifier } = parseParams({ params, schema: paramsSchema });
const user = notFoundIfFalsy(
@ -27,5 +23,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
customUrl: user.customUrl,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -1,6 +1,5 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
@ -8,10 +7,7 @@ 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 {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetUserResponse } from "../schema";
const paramsSchema = z.object({
@ -19,7 +15,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const t = await i18next.getFixedT("en", ["weapons"]);
@ -146,5 +141,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@ -34,6 +34,7 @@ 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 {
@ -51,7 +52,10 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e";
import { allI18nNamespaces } from "./utils/i18n";
import { isRevalidation, metaTags, type SerializeFrom } from "./utils/remix";
export const middleware: Route.MiddlewareFunction[] = [userMiddleware];
export const middleware: Route.MiddlewareFunction[] = [
apiCorsMiddleware,
userMiddleware,
];
import "nprogress/nprogress.css";
import "~/styles/common.css";

28
e2e/api-public.spec.ts Normal file
View File

@ -0,0 +1,28 @@
import { expect, seed, test } from "~/utils/playwright";
test.describe("Public API", () => {
test("OPTIONS preflight request returns 204 with CORS headers", async ({
page,
}) => {
await seed(page);
const response = await page.request.fetch("/api/tournament/1", {
method: "OPTIONS",
});
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
expect(response.headers()["access-control-allow-headers"]).toContain(
"Authorization",
);
});
test("GET request includes CORS headers in response", async ({ page }) => {
await seed(page);
const response = await page.request.fetch("/api/tournament/1");
expect(response.headers()["access-control-allow-origin"]).toBe("*");
});
});

74
package-lock.json generated
View File

@ -62,7 +62,6 @@
"remix-auth": "^4.2.0",
"remix-auth-oauth2": "^3.4.1",
"remix-i18next": "^7.4.2",
"remix-utils": "^9.0.0",
"slugify": "^1.6.6",
"swr": "^2.3.8",
"web-push": "^3.6.7",
@ -7399,7 +7398,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
@ -12844,65 +12843,6 @@
"react-router": "^7.0.0"
}
},
"node_modules/remix-utils": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-9.0.0.tgz",
"integrity": "sha512-xpDnw6hIjYbHR9/noE4lKNPRzfxvGai3XBQcjOjcwIwZVW9O1bdsnYAl+aqJ2fMXSQTNMjNuR8Cetn76HqwXCg==",
"funding": [
"https://github.com/sponsors/sergiodxa"
],
"license": "MIT",
"dependencies": {
"type-fest": "^4.41.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@edgefirst-dev/batcher": "^1.0.0",
"@edgefirst-dev/jwt": "^1.2.0",
"@edgefirst-dev/server-timing": "^0.0.1",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@standard-schema/spec": "^1.0.0",
"intl-parse-accept-language": "^1.0.0",
"is-ip": "^5.0.1",
"react": "^18.0.0 || ^19.0.0",
"react-router": "^7.0.0"
},
"peerDependenciesMeta": {
"@edgefirst-dev/batcher": {
"optional": true
},
"@edgefirst-dev/jwt": {
"optional": true
},
"@edgefirst-dev/server-timing": {
"optional": true
},
"@oslojs/crypto": {
"optional": true
},
"@oslojs/encoding": {
"optional": true
},
"@standard-schema/spec": {
"optional": true
},
"intl-parse-accept-language": {
"optional": true
},
"is-ip": {
"optional": true
},
"react": {
"optional": true
},
"react-router": {
"optional": true
}
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -13930,18 +13870,6 @@
"node": "*"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",

View File

@ -87,7 +87,6 @@
"remix-auth": "^4.2.0",
"remix-auth-oauth2": "^3.4.1",
"remix-i18next": "^7.4.2",
"remix-utils": "^9.0.0",
"slugify": "^1.6.6",
"swr": "^2.3.8",
"web-push": "^3.6.7",