Files
chess-scribe-website/src/server/lichess.ts
T

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 };
});
});