mirror of
https://github.com/TheRealOwenRees/chess-scribe-website.git
synced 2026-07-23 01:46:57 +00:00
214 lines
5.4 KiB
TypeScript
214 lines
5.4 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { redirect } from "@tanstack/react-router";
|
|
import { createServerFn } from "@tanstack/react-start";
|
|
import {
|
|
deleteCookie,
|
|
getCookie,
|
|
setCookie,
|
|
} from "@tanstack/react-start/server";
|
|
import { z } from "zod";
|
|
import { env } from "#/env.ts";
|
|
import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts";
|
|
import {
|
|
LichessAccountSchema,
|
|
SessionSchema,
|
|
TokenResponseSchema,
|
|
} from "#/schemas.ts";
|
|
|
|
const createVerifier = () => base64UrlEncode(randomBytes(32));
|
|
const createChallenge = (verifier: string) => base64UrlEncode(sha256(verifier));
|
|
|
|
const getRedirectUri = () =>
|
|
process.env.NODE_ENV === "production"
|
|
? `https://${env.LICHESS_CLIENT_ID}/callback`
|
|
: "http://localhost:3000/callback";
|
|
|
|
export const getSession = createServerFn({ method: "GET" }).handler(
|
|
async () => {
|
|
const session = getCookie("lichess-session");
|
|
if (!session) return null;
|
|
try {
|
|
const parsed = SessionSchema.safeParse(JSON.parse(session));
|
|
return parsed.success ? parsed.data : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
);
|
|
|
|
export const setSession = createServerFn({ method: "POST" })
|
|
.inputValidator(SessionSchema)
|
|
.handler(async ({ data }) => {
|
|
setCookie("lichess-session", JSON.stringify(data), {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 60 * 60 * 24 * 7,
|
|
});
|
|
|
|
setCookie("lichess-oauth-verifier", "", {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
return { username: data.username, id: data.id };
|
|
});
|
|
|
|
export const getToken = createServerFn({ method: "POST" })
|
|
.inputValidator(z.object({ code: z.string() }))
|
|
.handler(async ({ data }) => {
|
|
const verifier = getCookie("lichess-oauth-verifier");
|
|
|
|
if (!verifier) {
|
|
throw new Error("OAuth session expired. Please login again.");
|
|
}
|
|
|
|
const response = await fetch("https://lichess.org/api/token", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
grant_type: "authorization_code",
|
|
client_id: env.LICHESS_CLIENT_ID || "",
|
|
code: data.code,
|
|
redirect_uri: getRedirectUri(),
|
|
code_verifier: verifier,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Token exchange failed: ${await response.text()}`);
|
|
}
|
|
|
|
const json = await response.json();
|
|
return TokenResponseSchema.parse(json);
|
|
});
|
|
|
|
export const getUser = createServerFn({ method: "POST" })
|
|
.inputValidator(z.object({ access_token: z.string() }))
|
|
.handler(async ({ data }) => {
|
|
const response = await fetch("https://lichess.org/api/account", {
|
|
headers: {
|
|
Authorization: `Bearer ${data.access_token}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to fetch Lichess account");
|
|
|
|
const json = await response.json();
|
|
return LichessAccountSchema.parse(json);
|
|
});
|
|
|
|
const deleteAccessToken = createServerFn().handler(async () => {
|
|
const session = await getSession();
|
|
const token = session?.token;
|
|
|
|
if (token) {
|
|
const response = await fetch("https://lichess.org/api/token", {
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to delete access token: ${response.statusText}`);
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
return { success: false, message: "No token found to delete." };
|
|
});
|
|
|
|
export const logout = createServerFn().handler(async () => {
|
|
await deleteAccessToken();
|
|
deleteCookie("lichess-session", { path: "/" });
|
|
deleteCookie("lichess-oauth-verifier", { path: "/" }); // cookie shouldn't exist
|
|
});
|
|
|
|
export const login = createServerFn({ method: "GET" }).handler(async () => {
|
|
const verifier = createVerifier();
|
|
const challenge = createChallenge(verifier);
|
|
|
|
setCookie("lichess-oauth-verifier", verifier, {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 60 * 10,
|
|
});
|
|
|
|
const params = new URLSearchParams({
|
|
response_type: "code",
|
|
client_id: env.LICHESS_CLIENT_ID || "",
|
|
redirect_uri: getRedirectUri(),
|
|
scope: "study:read",
|
|
code_challenge_method: "S256",
|
|
code_challenge: challenge,
|
|
});
|
|
|
|
throw redirect({
|
|
href: `https://lichess.org/oauth?${params.toString()}`,
|
|
});
|
|
});
|
|
|
|
export const getUserStudies = createServerFn({ method: "POST" }).handler(
|
|
async () => {
|
|
const session = await getSession();
|
|
|
|
if (!session) return;
|
|
|
|
const response = await fetch(
|
|
`https://lichess.org/api/study/by/${session.username}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${session.token}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
const studies = await response.text();
|
|
|
|
return studies
|
|
.trim()
|
|
.split("\n")
|
|
.map((study) => JSON.parse(study));
|
|
},
|
|
);
|
|
|
|
export const getStudyChapters = createServerFn({ method: "POST" })
|
|
.inputValidator((data: { studyId: string }) => data)
|
|
.handler(async ({ data }) => {
|
|
const session = await getSession();
|
|
|
|
if (!session) return;
|
|
|
|
const response = await fetch(
|
|
`https://lichess.org/api/study/${data.studyId}.pgn`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${session.token}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
const text = await response.text();
|
|
|
|
return text
|
|
.trim()
|
|
.split(/\n{3,}/)
|
|
.map((game) => {
|
|
const eventHeaderText = game.match(/\[Event\s+"([^"]+)"]/)?.[1];
|
|
const eventName = eventHeaderText?.match(/(?<=:\s+)[^"]+/)?.[0] || "";
|
|
const siteHeaderText = game.match(/\[Site\s+"([^"]+)"]/)?.[1];
|
|
const chapterId = siteHeaderText?.match(/\/([^/]+)$/)?.[1] || "";
|
|
return { chapterId, pgn: game, name: eventName };
|
|
});
|
|
});
|