Skip to content

Commit ed2f7ed

Browse files
committed
✨ leaderboard api
1 parent 1a7c2a3 commit ed2f7ed

File tree

6 files changed

+292
-1
lines changed

6 files changed

+292
-1
lines changed

.github/workflows/main.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,18 @@ jobs:
2121
env:
2222
VITE_XR8_API_KEY: ${{ secrets.VITE_XR8_API_KEY }}
2323
- run: cp doc/* ./dist/
24+
2425
- uses: actions/upload-pages-artifact@v3
2526
with:
2627
path: dist
28+
2729
- uses: actions/deploy-pages@v4
2830
if: success() && github.ref == 'refs/heads/master'
31+
32+
- run: bunx wrangler deploy
33+
if: success() && github.ref == 'refs/heads/master'
34+
working-directory: src/api-leaderboard
35+
env:
36+
IMAGES_BUCKET_PUBLIC_URL: https://pub-6518f5a0bbad459698868dfbf8d106df.r2.dev
37+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
38+
GITHUB_ACCESS_TOKEN: ${{ secrets.LEADERBOARD_GITHUB_ACCESS_TOKEN }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
node_modules
22
dist
33
.env
4+
.dev.vars
5+
.wrangler

bun.lockb

27.2 KB
Binary file not shown.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
"@react-three/drei": "10.0.6",
1010
"@react-three/fiber": "9.1.2",
1111
"cannon-es": "0.20.0",
12+
"parse-multipart-data": "1.5.0",
1213
"react": "19.1.0",
1314
"react-dom": "19.1.0",
1415
"react-touch-visualizer": "0.0.1",
1516
"three": "0.175.0",
1617
"tunnel-rat": "0.1.2"
1718
},
1819
"devDependencies": {
20+
"@cloudflare/workers-types": "4.20250415.0",
1921
"@types/bun": "1.2.9",
2022
"@types/loadable__component": "5.13.9",
2123
"@types/react": "19.1.2",
@@ -24,7 +26,8 @@
2426
"@types/webxr": "0.5.21",
2527
"prettier": "2.8.8",
2628
"typescript": "5.8.3",
27-
"vite": "6.2.6"
29+
"vite": "6.2.6",
30+
"wrangler": "4.11.0"
2831
},
2932
"scripts": {
3033
"type": "tsc --noEmit",

src/api-leaderboard/index.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import * as crypto from "crypto";
2+
import { parse as multiPartParse } from "parse-multipart-data";
3+
import type { R2Bucket } from "@cloudflare/workers-types";
4+
import {
5+
categories,
6+
DiceValue,
7+
getScoreSheetScore,
8+
isScoreSheet,
9+
isScoreSheetFinished,
10+
} from "../gameRules/types";
11+
import { getScore } from "../gameRules/getScore";
12+
import { nDice } from "../gameRules/game";
13+
14+
const handler = async (req: Request, env: Env) => {
15+
const contentType = req.headers.get("content-type");
16+
if (!contentType?.startsWith("multipart/form-data;"))
17+
return new Response("Expect multipart", { status: 400 });
18+
19+
const boundary = contentType.split("boundary=")[1].trim();
20+
21+
const params = multiPartParse(Buffer.from(await req.arrayBuffer()), boundary);
22+
23+
const screenshots = new Map<string, string>();
24+
for (const category of categories) {
25+
const param = params.find((p) => p.name === category);
26+
27+
if (!param)
28+
return new Response(`missing screenshot for category "${category}"`, {
29+
status: 400,
30+
});
31+
32+
const hash = crypto
33+
.createHash("md5")
34+
.update(new Uint8Array(param.data))
35+
.digest("base64")
36+
.replaceAll(/[\/\+]/g, "")
37+
.padEnd(16, "=")
38+
.slice(0, 16)
39+
.toLowerCase();
40+
41+
if (param.data.byteLength > 10_000)
42+
return new Response("unexpected image format", { status: 400 });
43+
44+
const extension =
45+
(param.type === "image/jpeg" && ".jpeg") ||
46+
(param.type === "image/png" && ".png") ||
47+
(param.type === "image/webp" && ".webp") ||
48+
"";
49+
50+
const key = hash + extension;
51+
await env.images_bucket.put(key, param.data, {
52+
customMetadata: {
53+
"cache-control": "public, max-age=31536000, immutable",
54+
},
55+
});
56+
57+
screenshots.set(category, key);
58+
}
59+
60+
const gameParam = params.find((p) => p.name === "game");
61+
if (!gameParam) return new Response(`missing entry "game"`, { status: 400 });
62+
const game = JSON.parse(gameParam.data.toString());
63+
64+
const scoreSheet = game.scoreSheet;
65+
if (!isScoreSheet(scoreSheet, nDice) || !isScoreSheetFinished(scoreSheet))
66+
return new Response(`missing finished scoresheet"`, { status: 400 });
67+
68+
const user = "anonym";
69+
70+
const dicesToUnicode = (dices: DiceValue[]) =>
71+
dices
72+
.map(
73+
(d) =>
74+
(d === 1 && "⚀") ||
75+
(d === 2 && "⚁") ||
76+
(d === 3 && "⚂") ||
77+
(d === 4 && "⚃") ||
78+
(d === 5 && "⚄") ||
79+
(d === 6 && "⚅") ||
80+
"\xa0"
81+
)
82+
.join("");
83+
84+
const markdownBlock =
85+
`## ${getScoreSheetScore(scoreSheet)} by __${user}__\n` +
86+
"|Combination|Score|Screenshot|\n" +
87+
"|--|--|--|\n" +
88+
categories
89+
.map(
90+
(category) =>
91+
"| " +
92+
category.padEnd(
93+
Math.max(...categories.map((c) => c.length)),
94+
"\xa0"
95+
) +
96+
" | " +
97+
getScore(category, scoreSheet[category])
98+
.toString()
99+
.padStart(2, "\xa0") +
100+
" " +
101+
dicesToUnicode(scoreSheet[category]) +
102+
" | " +
103+
`<img width="50px" height="34" src="${
104+
env.IMAGES_BUCKET_PUBLIC_URL + screenshots.get(category)
105+
}"/>` +
106+
" |"
107+
)
108+
.join("\n");
109+
110+
const { entries, id } = await getLeaderboard(env);
111+
112+
if (entries.some((e) => e.block === markdownBlock))
113+
return new Response(`already submitted"`, { status: 400 });
114+
115+
entries.push({ score: getScoreSheetScore(scoreSheet), block: markdownBlock });
116+
entries.sort((a, b) => b.score - a.score);
117+
118+
while (entries.length > 10) entries.pop();
119+
120+
await setLeaderboard(env, id, entries);
121+
122+
const isTopScore = entries.some((b) => b.block === markdownBlock);
123+
const leaderboardUrl = `https://github.com/${env.LEADER_BOARD_REPOSITORY}/issues/${env.LEADER_BOARD_ISSUE_NUMBER}`;
124+
125+
return new Response(JSON.stringify({ isTopScore, leaderboardUrl }), {
126+
status: 200,
127+
headers: { "Content-Type": "application/json" },
128+
});
129+
};
130+
131+
const getLeaderboard = async (env: Env) => {
132+
const query = /* GraphQL */ `
133+
query (
134+
$repository_owner: String!
135+
$repository_name: String!
136+
$issue_number: Int!
137+
) {
138+
repository(name: $repository_name, owner: $repository_owner) {
139+
issue(number: $issue_number) {
140+
body
141+
id
142+
}
143+
}
144+
}
145+
`;
146+
const [repository_owner, repository_name] =
147+
env.LEADER_BOARD_REPOSITORY.split("/");
148+
const variables = {
149+
repository_owner,
150+
repository_name,
151+
issue_number: +env.LEADER_BOARD_ISSUE_NUMBER,
152+
};
153+
154+
const data = await githubGraphQLRequest<any>(env, query, variables);
155+
156+
const body = data.repository.issue.body as string;
157+
const id = data.repository.issue.id as string;
158+
const entries = [...body.matchAll(/## (\d+).*?\n(|.*|\n?)+/g)].map(
159+
([block, score]) => ({ block, score: +score })
160+
);
161+
162+
return { entries, id };
163+
};
164+
165+
const setLeaderboard = async (
166+
env: Env,
167+
id: string,
168+
entries: { block: string }[]
169+
) => {
170+
const query = /* GraphQL */ `
171+
mutation ($body: String!, $id: ID!) {
172+
updateIssue(input: { id: $id, body: $body }) {
173+
issue {
174+
body
175+
}
176+
}
177+
}
178+
`;
179+
180+
const variables = {
181+
id,
182+
body: entries.map((e) => e.block).join("\n"),
183+
};
184+
185+
await githubGraphQLRequest(env, query, variables);
186+
};
187+
188+
const githubGraphQLRequest = async <T extends unknown>(
189+
{ GITHUB_ACCESS_TOKEN }: { GITHUB_ACCESS_TOKEN: string },
190+
query: string,
191+
variables: any
192+
) => {
193+
const res = await fetch("https://api.github.com/graphql", {
194+
headers: {
195+
Authorization: `bearer ${GITHUB_ACCESS_TOKEN}`,
196+
"Content-Type": "application/json",
197+
"User-Agent": "me@platane.me",
198+
},
199+
method: "POST",
200+
body: JSON.stringify({ variables, query }),
201+
});
202+
203+
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText));
204+
205+
const { data, errors } = (await res.json()) as {
206+
data: any;
207+
errors?: { message: string }[];
208+
};
209+
210+
if (errors?.[0]) throw errors[0];
211+
212+
return data as T;
213+
};
214+
215+
type Env = {
216+
GITHUB_ACCESS_TOKEN: string;
217+
LEADER_BOARD_REPOSITORY: string;
218+
LEADER_BOARD_ISSUE_NUMBER: string;
219+
IMAGES_BUCKET_PUBLIC_URL: string;
220+
images_bucket: R2Bucket;
221+
};
222+
223+
const cors =
224+
<
225+
Req extends { headers: Headers },
226+
Res extends { headers: Headers },
227+
A extends Array<any>
228+
>(
229+
f: (req: Req, ...args: A) => Res | Promise<Res>
230+
) =>
231+
async (req: Req, ...args: A) => {
232+
const res = await f(req, ...args);
233+
234+
const origin = req.headers.get("origin");
235+
236+
if (origin) {
237+
const { host, hostname } = new URL(origin);
238+
239+
if (hostname === "localhost" || host === "platane.github.io")
240+
res.headers.set("Access-Control-Allow-Origin", origin);
241+
}
242+
243+
res.headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
244+
res.headers.set("Access-Control-Allow-Headers", "Content-Type");
245+
return res;
246+
};
247+
248+
export default {
249+
fetch: cors((req: Request, env: Env) => {
250+
const url = new URL(req.url);
251+
252+
if (req.method === "OPTIONS") return new Response();
253+
254+
if (req.method === "POST") return handler(req, env);
255+
256+
return new Response("", { status: 404 });
257+
}),
258+
};

src/api-leaderboard/wrangler.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name = "yar-htzee-leaderboard"
2+
3+
main = "./index.ts"
4+
5+
6+
compatibility_date = "2024-09-23"
7+
8+
compatibility_flags = ["nodejs_compat"]
9+
10+
[[r2_buckets]]
11+
binding = 'images_bucket'
12+
bucket_name = 'yar-htzee-leaderboard-images'
13+
14+
[vars]
15+
GITHUB_ACCESS_TOKEN = "XXX"
16+
LEADER_BOARD_REPOSITORY = "platane/yAR-htzee"
17+
LEADER_BOARD_ISSUE_NUMBER = "5"
18+
IMAGES_BUCKET_PUBLIC_URL = "https://example.com/"

0 commit comments

Comments
 (0)