Skip to content

Commit 3a47e8e

Browse files
committed
✨ leaderboard api
1 parent 1a7c2a3 commit 3a47e8e

File tree

7 files changed

+330
-1
lines changed

7 files changed

+330
-1
lines changed

.github/workflows/main.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,16 @@ 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+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_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/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# api leaderboard
2+
3+
Expose an endpoint to upload a player score with screenshots for each category.
4+
5+
Write the result on a github issue as markdown sorted list.
6+
7+
```sh
8+
9+
# deploy
10+
bunx wrangler deploy --branch=production
11+
12+
# change secret
13+
bunx wrangler secret put GITHUB_ACCESS_TOKEN
14+
15+
```

src/api-leaderboard/index.ts

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

src/api-leaderboard/wrangler.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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" # it's passed as a secret
16+
LEADER_BOARD_REPOSITORY = "platane/yAR-htzee"
17+
LEADER_BOARD_ISSUE_NUMBER = "5"
18+
IMAGES_BUCKET_PUBLIC_URL = "https://pub-6518f5a0bbad459698868dfbf8d106df.r2.dev/"
19+
20+
21+
# [observability.logs]
22+
# enabled = true

0 commit comments

Comments
 (0)