mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Add read/write API token types (#2756)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a57d1a71d2
commit
57892fb571
|
|
@ -42,5 +42,3 @@ VITE_SHOW_LUTI_NAV_ITEM=false
|
|||
VITE_VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_EMAIL=
|
||||
|
||||
PUBLIC_API_TOKENS=secret,secret2
|
||||
|
|
|
|||
|
|
@ -961,10 +961,13 @@ export interface UserFriendCode {
|
|||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
export type ApiTokenType = "read" | "write";
|
||||
|
||||
export interface ApiToken {
|
||||
id: GeneratedAlways<number>;
|
||||
userId: number;
|
||||
token: string;
|
||||
type: Generated<ApiTokenType>;
|
||||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import type { ApiTokenType } from "~/db/tables";
|
||||
import * as ApiRepository from "~/features/api/ApiRepository.server";
|
||||
|
||||
async function loadApiTokensCache() {
|
||||
const envTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? [];
|
||||
const dbTokens = await ApiRepository.allApiTokens();
|
||||
return new Set([...envTokens, ...dbTokens]);
|
||||
|
||||
const tokenMap = new Map<string, ApiTokenType>();
|
||||
|
||||
for (const { token, type } of dbTokens) {
|
||||
tokenMap.set(token, type);
|
||||
}
|
||||
|
||||
return tokenMap;
|
||||
}
|
||||
|
||||
let apiTokens = await loadApiTokensCache();
|
||||
|
|
@ -12,12 +19,16 @@ export async function refreshApiTokensCache() {
|
|||
apiTokens = await loadApiTokensCache();
|
||||
}
|
||||
|
||||
export function requireBearerAuth(req: Request) {
|
||||
function extractToken(req: Request) {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
throw new Response("Missing Authorization header", { status: 401 });
|
||||
}
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
return authHeader.replace("Bearer ", "");
|
||||
}
|
||||
|
||||
export function requireBearerAuth(req: Request) {
|
||||
const token = extractToken(req);
|
||||
if (!apiTokens.has(token)) {
|
||||
throw new Response("Invalid token", { status: 401 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,15 @@ describe("findTokenByUserId", () => {
|
|||
});
|
||||
|
||||
test("returns undefined when user has no token", async () => {
|
||||
const result = await ApiRepository.findTokenByUserId(1);
|
||||
const result = await ApiRepository.findTokenByUserId(1, "read");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("finds existing token for user", async () => {
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
|
||||
const result = await ApiRepository.findTokenByUserId(1);
|
||||
const result = await ApiRepository.findTokenByUserId(1, "read");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.userId).toBe(1);
|
||||
|
|
@ -28,16 +28,30 @@ describe("findTokenByUserId", () => {
|
|||
});
|
||||
|
||||
test("returns correct token for specific user", async () => {
|
||||
const token1 = await ApiRepository.generateToken(1);
|
||||
const token2 = await ApiRepository.generateToken(2);
|
||||
const token1 = await ApiRepository.generateToken(1, "read");
|
||||
const token2 = await ApiRepository.generateToken(2, "read");
|
||||
|
||||
const result1 = await ApiRepository.findTokenByUserId(1);
|
||||
const result2 = await ApiRepository.findTokenByUserId(2);
|
||||
const result1 = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const result2 = await ApiRepository.findTokenByUserId(2, "read");
|
||||
|
||||
expect(result1?.token).toBe(token1.token);
|
||||
expect(result2?.token).toBe(token2.token);
|
||||
expect(result1?.token).not.toBe(result2?.token);
|
||||
});
|
||||
|
||||
test("finds correct token by type", async () => {
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
await ApiRepository.generateToken(1, "write");
|
||||
|
||||
const readResult = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const writeResult = await ApiRepository.findTokenByUserId(1, "write");
|
||||
|
||||
expect(readResult).toBeDefined();
|
||||
expect(writeResult).toBeDefined();
|
||||
expect(readResult?.token).not.toBe(writeResult?.token);
|
||||
expect(readResult?.type).toBe("read");
|
||||
expect(writeResult?.type).toBe("write");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateToken", () => {
|
||||
|
|
@ -50,7 +64,7 @@ describe("generateToken", () => {
|
|||
});
|
||||
|
||||
test("creates new token for user", async () => {
|
||||
const result = await ApiRepository.generateToken(1);
|
||||
const result = await ApiRepository.generateToken(1, "read");
|
||||
|
||||
expect(result.token).toBeDefined();
|
||||
expect(typeof result.token).toBe("string");
|
||||
|
|
@ -58,19 +72,19 @@ describe("generateToken", () => {
|
|||
});
|
||||
|
||||
test("deletes existing token before creating new one", async () => {
|
||||
const firstToken = await ApiRepository.generateToken(1);
|
||||
const secondToken = await ApiRepository.generateToken(1);
|
||||
const firstToken = await ApiRepository.generateToken(1, "read");
|
||||
const secondToken = await ApiRepository.generateToken(1, "read");
|
||||
|
||||
expect(firstToken.token).not.toBe(secondToken.token);
|
||||
|
||||
const storedToken = await ApiRepository.findTokenByUserId(1);
|
||||
const storedToken = await ApiRepository.findTokenByUserId(1, "read");
|
||||
expect(storedToken?.token).toBe(secondToken.token);
|
||||
});
|
||||
|
||||
test("generates unique tokens for different users", async () => {
|
||||
const token1 = await ApiRepository.generateToken(1);
|
||||
const token2 = await ApiRepository.generateToken(2);
|
||||
const token3 = await ApiRepository.generateToken(3);
|
||||
const token1 = await ApiRepository.generateToken(1, "read");
|
||||
const token2 = await ApiRepository.generateToken(2, "read");
|
||||
const token3 = await ApiRepository.generateToken(3, "read");
|
||||
|
||||
expect(token1.token).not.toBe(token2.token);
|
||||
expect(token1.token).not.toBe(token3.token);
|
||||
|
|
@ -78,17 +92,28 @@ describe("generateToken", () => {
|
|||
});
|
||||
|
||||
test("replaces only the specific user's token", async () => {
|
||||
const user1FirstToken = await ApiRepository.generateToken(1);
|
||||
const user2Token = await ApiRepository.generateToken(2);
|
||||
const user1SecondToken = await ApiRepository.generateToken(1);
|
||||
const user1FirstToken = await ApiRepository.generateToken(1, "read");
|
||||
const user2Token = await ApiRepository.generateToken(2, "read");
|
||||
const user1SecondToken = await ApiRepository.generateToken(1, "read");
|
||||
|
||||
const result1 = await ApiRepository.findTokenByUserId(1);
|
||||
const result2 = await ApiRepository.findTokenByUserId(2);
|
||||
const result1 = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const result2 = await ApiRepository.findTokenByUserId(2, "read");
|
||||
|
||||
expect(result1?.token).toBe(user1SecondToken.token);
|
||||
expect(result1?.token).not.toBe(user1FirstToken.token);
|
||||
expect(result2?.token).toBe(user2Token.token);
|
||||
});
|
||||
|
||||
test("allows same user to have both read and write tokens", async () => {
|
||||
const readToken = await ApiRepository.generateToken(1, "read");
|
||||
const writeToken = await ApiRepository.generateToken(1, "write");
|
||||
|
||||
const readResult = await ApiRepository.findTokenByUserId(1, "read");
|
||||
const writeResult = await ApiRepository.findTokenByUserId(1, "write");
|
||||
|
||||
expect(readResult?.token).toBe(readToken.token);
|
||||
expect(writeResult?.token).toBe(writeToken.token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allApiTokens", () => {
|
||||
|
|
@ -106,12 +131,17 @@ describe("allApiTokens", () => {
|
|||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns array of token strings", async () => {
|
||||
await ApiRepository.generateToken(1);
|
||||
test("returns array of token objects with type", async () => {
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
|
||||
const result = await ApiRepository.allApiTokens();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.every((token) => typeof token === "string")).toBe(true);
|
||||
expect(
|
||||
result.every(
|
||||
(item) =>
|
||||
typeof item.token === "string" && typeof item.type === "string",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,48 +1,43 @@
|
|||
import { nanoid } from "nanoid";
|
||||
import { db } from "~/db/sql";
|
||||
import type { ApiTokenType } from "~/db/tables";
|
||||
|
||||
const API_TOKEN_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* Finds an API token for the given user ID.
|
||||
* @returns API token record if found, undefined otherwise
|
||||
*/
|
||||
export function findTokenByUserId(userId: number) {
|
||||
/** Finds an API token for the given user ID and type. */
|
||||
export function findTokenByUserId(userId: number, type: ApiTokenType) {
|
||||
return db
|
||||
.selectFrom("ApiToken")
|
||||
.selectAll()
|
||||
.where("userId", "=", userId)
|
||||
.where("type", "=", type)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new API token for the given user.
|
||||
* Deletes any existing token for the user before creating a new one.
|
||||
* @returns Object containing the newly generated token
|
||||
*/
|
||||
export function generateToken(userId: number) {
|
||||
/** Generates a new API token for the given user. Deletes any existing token of the same type before creating a new one. */
|
||||
export function generateToken(userId: number, type: ApiTokenType) {
|
||||
const token = nanoid(API_TOKEN_LENGTH);
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("ApiToken").where("userId", "=", userId).execute();
|
||||
await trx
|
||||
.deleteFrom("ApiToken")
|
||||
.where("userId", "=", userId)
|
||||
.where("type", "=", type)
|
||||
.execute();
|
||||
|
||||
return trx
|
||||
.insertInto("ApiToken")
|
||||
.values({
|
||||
userId,
|
||||
token,
|
||||
type,
|
||||
})
|
||||
.returning("token")
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all valid API tokens from users with API access.
|
||||
* Includes tokens from users with the isApiAccesser flag enabled (includes supporters tier 2+),
|
||||
* or users who are ADMIN, ORGANIZER, or STREAMER members of established tournament organizations.
|
||||
* @returns Array of valid API token strings
|
||||
*/
|
||||
/** Retrieves all valid API tokens and their types from users with API access. */
|
||||
export async function allApiTokens() {
|
||||
const tokens = await db
|
||||
.selectFrom("ApiToken")
|
||||
|
|
@ -57,7 +52,7 @@ export async function allApiTokens() {
|
|||
"TournamentOrganization.id",
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
)
|
||||
.select("ApiToken.token")
|
||||
.select(["ApiToken.token", "ApiToken.type"])
|
||||
// NOTE: permissions logic also exists in checkUserHasApiAccess function
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
|
|
@ -77,5 +72,5 @@ export async function allApiTokens() {
|
|||
.groupBy("ApiToken.token")
|
||||
.execute();
|
||||
|
||||
return tokens.map((row) => row.token);
|
||||
return tokens.map((row) => ({ token: row.token, type: row.type }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@ import { z } from "zod";
|
|||
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseRequestPayload, successToast } from "~/utils/remix.server";
|
||||
import { _action } from "~/utils/zod";
|
||||
import * as ApiRepository from "../ApiRepository.server";
|
||||
import { checkUserHasApiAccess } from "../core/perms";
|
||||
|
||||
const apiActionSchema = z.object({
|
||||
_action: _action("GENERATE"),
|
||||
_action: z.enum(["GENERATE_READ", "GENERATE_WRITE"]),
|
||||
});
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
|
@ -24,12 +23,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
}
|
||||
|
||||
switch (data._action) {
|
||||
case "GENERATE": {
|
||||
await ApiRepository.generateToken(user.id);
|
||||
|
||||
case "GENERATE_READ": {
|
||||
await ApiRepository.generateToken(user.id, "read");
|
||||
await refreshApiTokensCache();
|
||||
|
||||
successToast("API token generated successfully");
|
||||
successToast("Read token generated successfully");
|
||||
break;
|
||||
}
|
||||
case "GENERATE_WRITE": {
|
||||
await ApiRepository.generateToken(user.id, "write");
|
||||
await refreshApiTokensCache();
|
||||
successToast("Write token generated successfully");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
|||
test("both functions grant access for isApiAccesser flag", async () => {
|
||||
await AdminRepository.makeApiAccesserByUserId(1);
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
|
|
@ -32,7 +32,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
|||
test("both functions grant access for isTournamentOrganizer flag", async () => {
|
||||
await AdminRepository.makeTournamentOrganizerByUserId(1);
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
|
|
@ -50,7 +50,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
|||
patronTill: add(new Date(), { months: 3 }),
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
|
|
@ -68,7 +68,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
|||
patronTill: add(new Date(), { months: 3 }),
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
await ApiRepository.generateToken(1, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
|
|
@ -101,7 +101,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
|||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(userId);
|
||||
await ApiRepository.generateToken(userId, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(userId);
|
||||
|
|
@ -131,7 +131,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
|||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(2);
|
||||
await ApiRepository.generateToken(2, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(2);
|
||||
|
|
@ -158,7 +158,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA
|
|||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(2);
|
||||
await ApiRepository.generateToken(2, "read");
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(2);
|
||||
|
|
|
|||
|
|
@ -10,14 +10,19 @@ export const loader = async () => {
|
|||
if (!hasApiAccess) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
apiToken: null,
|
||||
readToken: null,
|
||||
writeToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
const apiToken = await ApiRepository.findTokenByUserId(user.id);
|
||||
const [readToken, writeToken] = await Promise.all([
|
||||
ApiRepository.findTokenByUserId(user.id, "read"),
|
||||
ApiRepository.findTokenByUserId(user.id, "write"),
|
||||
]);
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
apiToken: apiToken?.token ?? null,
|
||||
readToken: readToken?.token ?? null,
|
||||
writeToken: writeToken?.token ?? null,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,12 +45,55 @@ export default function ApiPage() {
|
|||
<div>
|
||||
<FormMessage type="info">{t("common:api.noAccess")}</FormMessage>
|
||||
</div>
|
||||
) : data.apiToken ? (
|
||||
) : (
|
||||
<div className="stack lg">
|
||||
<TokenSection
|
||||
token={data.readToken}
|
||||
tokenType="read"
|
||||
generateAction="GENERATE_READ"
|
||||
/>
|
||||
<TokenSection
|
||||
token={data.writeToken}
|
||||
tokenType="write"
|
||||
generateAction="GENERATE_WRITE"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenSection({
|
||||
token,
|
||||
tokenType,
|
||||
generateAction,
|
||||
}: {
|
||||
token: string | null;
|
||||
tokenType: "read" | "write";
|
||||
generateAction: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const isWriteToken = tokenType === "write";
|
||||
const labelKey = isWriteToken
|
||||
? "common:api.writeTokenLabel"
|
||||
: "common:api.readTokenLabel";
|
||||
const descriptionKey = isWriteToken
|
||||
? "common:api.writeTokenDescription"
|
||||
: "common:api.readTokenDescription";
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<h2 className="text-md">{t(labelKey)}</h2>
|
||||
<p className="text-xs text-lighter">{t(descriptionKey)}</p>
|
||||
</div>
|
||||
|
||||
{token ? (
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<label>{t("common:api.tokenLabel")}</label>
|
||||
<CopyToClipboardPopover
|
||||
url={data.apiToken}
|
||||
url={token}
|
||||
trigger={
|
||||
<SendouButton icon={<EyeIcon />}>
|
||||
{t("common:api.revealButton")}
|
||||
|
|
@ -62,12 +105,12 @@ export default function ApiPage() {
|
|||
<FormWithConfirm
|
||||
dialogHeading={t("common:api.regenerate.heading")}
|
||||
submitButtonText={t("common:api.regenerate.confirm")}
|
||||
fields={[["_action", "GENERATE"]]}
|
||||
fields={[["_action", generateAction]]}
|
||||
>
|
||||
<SendouButton
|
||||
className="mx-auto"
|
||||
variant="outlined"
|
||||
icon={<RefreshArrowsIcon />}
|
||||
className="mr-auto"
|
||||
>
|
||||
{t("common:api.regenerate.button")}
|
||||
</SendouButton>
|
||||
|
|
@ -75,11 +118,11 @@ export default function ApiPage() {
|
|||
</div>
|
||||
) : (
|
||||
<form method="post">
|
||||
<SubmitButton _action="GENERATE">
|
||||
<SubmitButton _action={generateAction}>
|
||||
{t("common:api.generate")}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)}
|
||||
</Main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -1,4 +1,5 @@
|
|||
import { expect, seed, test } from "~/utils/playwright";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
|
||||
|
||||
test.describe("Public API", () => {
|
||||
test("OPTIONS preflight request returns 204 with CORS headers", async ({
|
||||
|
|
@ -25,4 +26,34 @@ test.describe("Public API", () => {
|
|||
|
||||
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||
});
|
||||
|
||||
test("creates read API token and calls public endpoint", async ({ page }) => {
|
||||
await seed(page);
|
||||
await impersonate(page);
|
||||
|
||||
await navigate({ page, url: "/api" });
|
||||
|
||||
await page.locator("form").first().getByRole("button").click();
|
||||
await page.waitForURL("/api");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /reveal/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const token = await page.locator("input[readonly]").inputValue();
|
||||
expect(token).toBeTruthy();
|
||||
expect(token.length).toBe(20);
|
||||
|
||||
const response = await page.request.fetch(`/api/user/${ADMIN_ID}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(ADMIN_ID);
|
||||
expect(data.name).toBe("Sendou");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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.
|
|
@ -323,7 +323,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -323,7 +323,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -323,7 +323,10 @@
|
|||
"api.title": "API Access",
|
||||
"api.description": "Generate an API token to access the sendou.ink API. See the <1>API documentation</1> for available endpoints, usage examples and guidelines to follow.",
|
||||
"api.noAccess": "You do not have access to the API. Access is granted to supporters (Supporter tier or higher) and admins, organizers, or streamers of established tournament organizations.",
|
||||
"api.tokenLabel": "Your API Token",
|
||||
"api.readTokenLabel": "Read Token",
|
||||
"api.readTokenDescription": "Use this token to access read-only API endpoints.",
|
||||
"api.writeTokenLabel": "Write Token",
|
||||
"api.writeTokenDescription": "Use this token to access both read and write API endpoints.",
|
||||
"api.revealButton": "Click to reveal",
|
||||
"api.regenerate.heading": "Regenerating will invalidate your current token. Any applications using the old token will stop working.",
|
||||
"api.regenerate.button": "Regenerate token",
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -324,7 +324,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -319,7 +319,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -319,7 +319,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -323,7 +323,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -326,7 +326,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -326,7 +326,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
|
|
@ -319,7 +319,10 @@
|
|||
"api.title": "",
|
||||
"api.description": "",
|
||||
"api.noAccess": "",
|
||||
"api.tokenLabel": "",
|
||||
"api.readTokenLabel": "",
|
||||
"api.readTokenDescription": "",
|
||||
"api.writeTokenLabel": "",
|
||||
"api.writeTokenDescription": "",
|
||||
"api.revealButton": "",
|
||||
"api.regenerate.heading": "",
|
||||
"api.regenerate.button": "",
|
||||
|
|
|
|||
34
migrations/117-api-token-types.js
Normal file
34
migrations/117-api-token-types.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export function up(db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
create table "ApiToken_new" (
|
||||
"id" integer primary key,
|
||||
"token" text not null unique,
|
||||
"userId" integer not null,
|
||||
"type" text not null default 'read',
|
||||
"createdAt" integer default (strftime('%s', 'now')) not null,
|
||||
foreign key ("userId") references "User"("id") on delete cascade
|
||||
) strict
|
||||
`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
insert into "ApiToken_new" ("id", "token", "userId", "type", "createdAt")
|
||||
select "id", "token", "userId", 'read', "createdAt"
|
||||
from "ApiToken"
|
||||
`,
|
||||
).run();
|
||||
|
||||
db.prepare(/* sql */ "drop table ApiToken").run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `alter table "ApiToken_new" rename to "ApiToken"`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `create unique index api_token_user_id_type on "ApiToken"("userId", "type")`,
|
||||
).run();
|
||||
})();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user