Add German (DE) language support, self-hostable Daily API, and repo cleanup
German Translation (complete i18n): - Full DE translation block (146 keys matching FR/EN/UA coverage) - All 37 categories with German name and description - 228 country names in country.json (country_DE field) - 10 inline fallback countries with country_DE - getCountryName() and all ternary expressions handle DE - 399 country briefs with DE field - 16 preset labels made translatable via data-i18n - Browser language auto-detection (navigator.language) with FR fallback - DE language button in UI Self-Hostable Daily API: - Node.js server (api/server.js) converted from Cloudflare Worker - In-memory KV store with TTL, SHA-256 IP hashing - Configurable PORT and CORS_ORIGINS via environment variables - Docker-ready with api/Dockerfile (node:22-alpine) - docker-compose.yml for easy self-hosting (nginx + api) - config.example.js for API URL override - Default DAILY_API falls back to original Cloudflare Worker (no breaking change) UX Improvements: - Replaced single "Play again" button with two: "Back to menu" + "Play again" - Added replayGame() for instant same-mode replay - Hardcoded French alert() strings replaced with t() translations - Added favicon (globe emoji SVG) Repo Cleanup: - Removed 12 unused numbered HTML snapshots - Removed archive/, .DS_Store files, Cloudflare Worker source - Removed .docx and .xlsx dev documents - Added .gitignore - Rewrote README.md with game description and self-hosting guide
This commit is contained in:
parent
34166dc84a
commit
e6c7f763ac
39 changed files with 1016 additions and 77315 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.DS_Store
|
||||
*.DS_Store
|
||||
node_modules/
|
||||
.wrangler/
|
||||
*.docx
|
||||
*.xlsx
|
||||
config.js
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Binary file not shown.
105
README.md
105
README.md
|
|
@ -1,9 +1,102 @@
|
|||
Play the game:
|
||||
[https://mathieuviart.github.io/legrandquiz/](https://mathieuviart.github.io/legrandgeoquiz/)
|
||||
# LeGrandGeoQuiz
|
||||
|
||||
The strategic geography game — 8 countries, 8 categories, the lowest score possible.
|
||||
A strategic geography quiz game — 8 countries, 8 categories, the lowest score wins.
|
||||
|
||||
Supported language:
|
||||
FR - EN - UA
|
||||
**Languages:** FR | EN | UA | DE
|
||||
|
||||
Open source project developed by Mathieu VIART
|
||||
## How to Play
|
||||
|
||||
1. Each round, a country is presented to the player
|
||||
2. Assign it to one of the available statistical categories
|
||||
3. Your score = the country's rank in that category (lower is better)
|
||||
4. Each category can only be used **once**
|
||||
5. You have **20 seconds** per country — otherwise: +200 penalty points!
|
||||
6. **Goal:** minimize your total score
|
||||
|
||||
## Game Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Normal** | Country name, flag and hints visible |
|
||||
| **Hardcore** | Only the flag is visible — name and hints are hidden |
|
||||
| **Reverse** | Choose the right country for a given category (instead of the other way around) |
|
||||
| **Daily** | Same seed for all players, renewed daily at midnight. Leaderboard included |
|
||||
| **Custom** | Configure time, number of countries, select specific countries/categories |
|
||||
|
||||
## Categories
|
||||
|
||||
35+ statistical categories sourced from official data, including:
|
||||
|
||||
- GDP (Total & per capita), Population, Population density
|
||||
- Peace index, Corruption index, Happiness index
|
||||
- FIFA rankings (M/F), Rugby ranking, Basketball ranking
|
||||
- Life expectancy, Fertility rate, Median age, Suicide rate
|
||||
- Ecological footprint, Forest cover, Gold production
|
||||
- Olympic medals, Highest point, Alphabetical order
|
||||
- and more...
|
||||
|
||||
## Data Sources
|
||||
|
||||
Country statistics are loaded from `country.json` containing rankings for 228 countries across all categories. Data sourced from World Bank, UN, FIFA, World Rugby, FIBA, and other official institutions.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
- **Frontend:** Single-page HTML5/CSS3/Vanilla JS (no build step)
|
||||
- **Data:** `country.json` (228 countries, 35+ statistical categories)
|
||||
- **Fonts:** Syne + DM Mono (Google Fonts)
|
||||
- **Emoji:** Twemoji for consistent flag rendering
|
||||
- **Daily API:** Self-hostable Node.js REST API for daily mode (seed, leaderboard, anti-cheat)
|
||||
|
||||
## Self-Hosting with Docker Compose
|
||||
|
||||
The easiest way to self-host the game with the Daily API:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/joshii-h/legrandgeoquiz.git
|
||||
cd legrandgeoquiz
|
||||
cp config.example.js config.js
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The game is available at `http://localhost:8080` and the API at `http://localhost:3000`.
|
||||
|
||||
### Custom Domain Setup
|
||||
|
||||
If hosting on a custom domain with HTTPS:
|
||||
|
||||
1. Edit `config.js` to set your API URL:
|
||||
```js
|
||||
window.GEOQUIZ_API = "https://api.yourdomain.com";
|
||||
```
|
||||
|
||||
2. Set the `CORS_ORIGINS` environment variable in `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- CORS_ORIGINS=https://yourdomain.com
|
||||
```
|
||||
|
||||
3. Place a reverse proxy (Traefik, nginx, Caddy) in front for TLS termination.
|
||||
|
||||
### Without Docker
|
||||
|
||||
```bash
|
||||
# Serve the game (any static file server works)
|
||||
python3 -m http.server 8080
|
||||
|
||||
# Run the Daily API
|
||||
cd api && node server.js
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/seed` | Today's daily seed token |
|
||||
| `GET` | `/api/played` | Check if current IP already played today |
|
||||
| `POST` | `/api/score` | Submit a score `{pseudo, score, seed_date}` |
|
||||
| `GET` | `/api/leaderboard?date=YYYY-MM-DD` | Top 20 scores for a given day |
|
||||
|
||||
## Credits
|
||||
|
||||
Original game by [Mathieu VIART](https://github.com/mathieuviart/legrandgeoquiz).
|
||||
German translation, i18n improvements, and self-hosting setup by [joshii-h](https://github.com/joshii-h).
|
||||
|
|
|
|||
6
api/Dockerfile
Normal file
6
api/Dockerfile
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY server.js .
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
CMD ["node", "server.js"]
|
||||
174
api/server.js
Normal file
174
api/server.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const ALLOWED_ORIGINS = [
|
||||
...(process.env.CORS_ORIGINS || '').split(',').filter(Boolean),
|
||||
'http://localhost:8080',
|
||||
'http://localhost:3000',
|
||||
];
|
||||
|
||||
// ── In-memory KV with TTL ────────────────────────────────────────────────────
|
||||
const store = new Map();
|
||||
|
||||
function kvPut(key, value, ttlSeconds) {
|
||||
store.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||||
}
|
||||
|
||||
function kvGet(key) {
|
||||
const entry = store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expires) { store.delete(key); return null; }
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
// Cleanup expired keys every 10 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of store) { if (now > v.expires) store.delete(k); }
|
||||
}, 600000);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function todayUTC() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dailySeedFromDate(dateStr) {
|
||||
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < dateStr.length; i++) {
|
||||
hash = ((hash << 5) + hash) + dateStr.charCodeAt(i);
|
||||
hash = hash & 0x7FFFFFFF;
|
||||
}
|
||||
let result = '';
|
||||
let n = hash;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result = BASE62[n % 62] + result;
|
||||
n = Math.floor(n / 62);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function hashIP(ip) {
|
||||
return crypto.createHash('sha256')
|
||||
.update(ip + '_legrandgeoquiz_salt_2025')
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function getIP(req) {
|
||||
return (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
||||
|| req.headers['x-real-ip']
|
||||
|| req.socket.remoteAddress
|
||||
|| 'unknown';
|
||||
}
|
||||
|
||||
function jsonResponse(res, data, status, origin) {
|
||||
const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': allowed,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', c => { body += c; if (body.length > 1e5) reject(new Error('Too large')); });
|
||||
req.on('end', () => { try { resolve(JSON.parse(body)); } catch { reject(new Error('Invalid JSON')); } });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Server ───────────────────────────────────────────────────────────────────
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const origin = req.headers['origin'] || '';
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return jsonResponse(res, {}, 200, origin);
|
||||
}
|
||||
|
||||
// GET /api/seed
|
||||
if (path === '/api/seed' && req.method === 'GET') {
|
||||
const date = todayUTC();
|
||||
const token = dailySeedFromDate(date);
|
||||
const expires = new Date();
|
||||
expires.setUTCHours(23, 59, 59, 999);
|
||||
const ttl = Math.floor((expires - Date.now()) / 1000) + 3600;
|
||||
kvPut(`seed:${date}`, token, Math.max(ttl, 3600));
|
||||
return jsonResponse(res, { date, token }, 200, origin);
|
||||
}
|
||||
|
||||
// GET /api/played
|
||||
if (path === '/api/played' && req.method === 'GET') {
|
||||
const date = todayUTC();
|
||||
const ipHash = hashIP(getIP(req));
|
||||
const played = kvGet(`played:${date}:${ipHash}`);
|
||||
return jsonResponse(res, { played: played !== null }, 200, origin);
|
||||
}
|
||||
|
||||
// POST /api/score
|
||||
if (path === '/api/score' && req.method === 'POST') {
|
||||
let body;
|
||||
try { body = await parseBody(req); } catch {
|
||||
return jsonResponse(res, { error: 'Invalid JSON' }, 400, origin);
|
||||
}
|
||||
|
||||
const { pseudo, score, seed_date } = body;
|
||||
if (!pseudo || typeof score !== 'number' || !seed_date) {
|
||||
return jsonResponse(res, { error: 'Missing fields' }, 400, origin);
|
||||
}
|
||||
if (pseudo.length > 20 || pseudo.length < 1) {
|
||||
return jsonResponse(res, { error: 'Pseudo must be 1-20 characters' }, 400, origin);
|
||||
}
|
||||
if (score < 0 || score > 99999) {
|
||||
return jsonResponse(res, { error: 'Invalid score' }, 400, origin);
|
||||
}
|
||||
|
||||
const today = todayUTC();
|
||||
if (seed_date !== today) {
|
||||
return jsonResponse(res, { error: 'Seed expired' }, 403, origin);
|
||||
}
|
||||
|
||||
const ipHash = hashIP(getIP(req));
|
||||
const playedKey = `played:${today}:${ipHash}`;
|
||||
if (kvGet(playedKey) !== null) {
|
||||
return jsonResponse(res, { error: 'Already played today', already_played: true }, 403, origin);
|
||||
}
|
||||
|
||||
kvPut(playedKey, '1', 30 * 3600);
|
||||
|
||||
const lbKey = `scores:${today}`;
|
||||
const existing = kvGet(lbKey);
|
||||
const scores = existing ? JSON.parse(existing) : [];
|
||||
|
||||
scores.push({
|
||||
pseudo: pseudo.trim().replace(/[<>]/g, ''),
|
||||
score,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
scores.sort((a, b) => a.score - b.score);
|
||||
const top100 = scores.slice(0, 100);
|
||||
kvPut(lbKey, JSON.stringify(top100), 48 * 3600);
|
||||
|
||||
const rank = top100.findIndex(s => s.pseudo === pseudo.trim() && s.score === score) + 1;
|
||||
return jsonResponse(res, { success: true, rank, total: top100.length }, 200, origin);
|
||||
}
|
||||
|
||||
// GET /api/leaderboard
|
||||
if (path === '/api/leaderboard' && req.method === 'GET') {
|
||||
const date = url.searchParams.get('date') || todayUTC();
|
||||
const data = kvGet(`scores:${date}`);
|
||||
const scores = data ? JSON.parse(data) : [];
|
||||
return jsonResponse(res, { date, scores: scores.slice(0, 20), total: scores.length }, 200, origin);
|
||||
}
|
||||
|
||||
jsonResponse(res, { error: 'Not found' }, 404, origin);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log(`GeoQuiz API listening on :${PORT}`));
|
||||
9015
archive/country.json
9015
archive/country.json
File diff suppressed because it is too large
Load diff
6
config.example.js
Normal file
6
config.example.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// LeGrandGeoQuiz Configuration
|
||||
// Copy this file to config.js and adjust for your deployment:
|
||||
//
|
||||
// window.GEOQUIZ_API = "https://your-api-domain.com";
|
||||
//
|
||||
// If not set, the Daily API defaults to http://localhost:3000
|
||||
10874
country.json
10874
country.json
File diff suppressed because one or more lines are too long
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- .:/usr/share/nginx/html:ro
|
||||
|
||||
api:
|
||||
build: ./api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- CORS_ORIGINS=http://localhost:8080
|
||||
1
favicon.svg
Normal file
1
favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌍</text></svg>
|
||||
|
After Width: | Height: | Size: 110 B |
1177
index.html
1177
index.html
File diff suppressed because it is too large
Load diff
BIN
legrandgeoquiz-api/.DS_Store
vendored
BIN
legrandgeoquiz-api/.DS_Store
vendored
Binary file not shown.
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"account": {
|
||||
"id": "d30b5222bc1dddb6c64b197729711c19",
|
||||
"name": "Mathieu.viart73@gmail.com's Account"
|
||||
}
|
||||
}
|
||||
BIN
legrandgeoquiz-api/spring-glade-0b4f/.DS_Store
vendored
BIN
legrandgeoquiz-api/spring-glade-0b4f/.DS_Store
vendored
Binary file not shown.
|
|
@ -1,12 +0,0 @@
|
|||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
167
legrandgeoquiz-api/spring-glade-0b4f/.gitignore
vendored
167
legrandgeoquiz-api/spring-glade-0b4f/.gitignore
vendored
|
|
@ -1,167 +0,0 @@
|
|||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# wrangler project
|
||||
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
.env*
|
||||
!.env.example
|
||||
.wrangler/
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"useTabs": true
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"files.associations": {
|
||||
"wrangler.json": "jsonc"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# Cloudflare Workers
|
||||
|
||||
STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task.
|
||||
|
||||
## Docs
|
||||
|
||||
- https://developers.cloudflare.com/workers/
|
||||
- MCP: `https://docs.mcp.cloudflare.com/mcp`
|
||||
|
||||
For all limits and quotas, retrieve from the product's `/platform/limits/` page. eg. `/workers/platform/limits`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npx wrangler dev` | Local development |
|
||||
| `npx wrangler deploy` | Deploy to Cloudflare |
|
||||
| `npx wrangler types` | Generate TypeScript types |
|
||||
|
||||
Run `wrangler types` after changing bindings in wrangler.jsonc.
|
||||
|
||||
## Node.js Compatibility
|
||||
|
||||
https://developers.cloudflare.com/workers/runtime-apis/nodejs/
|
||||
|
||||
## Errors
|
||||
|
||||
- **Error 1102** (CPU/Memory exceeded): Retrieve limits from `/workers/platform/limits/`
|
||||
- **All errors**: https://developers.cloudflare.com/workers/observability/errors/
|
||||
|
||||
## Product Docs
|
||||
|
||||
Retrieve API references and limits from:
|
||||
`/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/`
|
||||
2588
legrandgeoquiz-api/spring-glade-0b4f/package-lock.json
generated
2588
legrandgeoquiz-api/spring-glade-0b4f/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"name": "spring-glade-0b4f",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.12.4",
|
||||
"vitest": "~3.2.0",
|
||||
"wrangler": "^4.68.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
/**
|
||||
* LeGrandGeoQuiz — Cloudflare Worker API
|
||||
* Routes :
|
||||
* GET /api/seed → seed du jour (Base62, déterministe)
|
||||
* POST /api/score → soumettre un score { pseudo, score, seed_date }
|
||||
* GET /api/leaderboard?date=YYYY-MM-DD → top 20 du jour
|
||||
* GET /api/played → est-ce que cette IP a déjà joué aujourd'hui ?
|
||||
*/
|
||||
|
||||
// ── Origines autorisées (ton GitHub Pages + local pour dev) ───────────────────
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://mathieuviart.github.io',
|
||||
'http://localhost:8080',
|
||||
'http://127.0.0.1:8080',
|
||||
'http://localhost:5500', // Live Server VS Code
|
||||
];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function todayUTC() {
|
||||
return new Date().toISOString().slice(0, 10); // "2025-02-25"
|
||||
}
|
||||
|
||||
// Seed déterministe depuis la date — même calcul que côté client
|
||||
// On utilise un hash simple de la chaîne date → nombre → Base62
|
||||
function dailySeedFromDate(dateStr) {
|
||||
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
// Hash djb2 de la date
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < dateStr.length; i++) {
|
||||
hash = ((hash << 5) + hash) + dateStr.charCodeAt(i);
|
||||
hash = hash & 0x7FFFFFFF; // garde positif sur 31 bits
|
||||
}
|
||||
// Convertir en Base62 (6 caractères suffisent comme graine pseudo-aléatoire)
|
||||
let result = '';
|
||||
let n = hash;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result = BASE62[n % 62] + result;
|
||||
n = Math.floor(n / 62);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Hash de l'IP pour ne pas stocker l'IP brute (RGPD)
|
||||
async function hashIP(ip) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(ip + '_legrandgeoquiz_salt_2025');
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
|
||||
}
|
||||
|
||||
// Récupère l'IP du joueur depuis les headers Cloudflare
|
||||
function getIP(request) {
|
||||
return request.headers.get('CF-Connecting-IP')
|
||||
|| request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
|
||||
|| 'unknown';
|
||||
}
|
||||
|
||||
// Réponse JSON avec les bons headers CORS
|
||||
function jsonResponse(data, status = 200, origin = '') {
|
||||
const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': allowed,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handler principal ─────────────────────────────────────────────────────────
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const origin = request.headers.get('Origin') || '';
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Preflight CORS (navigateurs envoient OPTIONS avant POST)
|
||||
if (request.method === 'OPTIONS') {
|
||||
return jsonResponse({}, 200, origin);
|
||||
}
|
||||
|
||||
// ── GET /api/seed ─────────────────────────────────────────────────────────
|
||||
if (path === '/api/seed' && request.method === 'GET') {
|
||||
const date = todayUTC();
|
||||
const token = dailySeedFromDate(date);
|
||||
|
||||
// On stocke le token dans KV pour que le client puisse vérifier
|
||||
// (expiration automatique à minuit UTC + 1h de marge)
|
||||
const expires = new Date();
|
||||
expires.setUTCHours(23, 59, 59, 999);
|
||||
const ttl = Math.floor((expires - Date.now()) / 1000) + 3600;
|
||||
|
||||
await env.QUIZ_DATA.put(`seed:${date}`, token, { expirationTtl: Math.max(ttl, 3600) });
|
||||
|
||||
return jsonResponse({ date, token });
|
||||
}
|
||||
|
||||
// ── GET /api/played ───────────────────────────────────────────────────────
|
||||
if (path === '/api/played' && request.method === 'GET') {
|
||||
const date = todayUTC();
|
||||
const ip = getIP(request);
|
||||
const ipHash = await hashIP(ip);
|
||||
const key = `played:${date}:${ipHash}`;
|
||||
const played = await env.QUIZ_DATA.get(key);
|
||||
|
||||
return jsonResponse({ played: played !== null });
|
||||
}
|
||||
|
||||
// ── POST /api/score ───────────────────────────────────────────────────────
|
||||
if (path === '/api/score' && request.method === 'POST') {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: 'Invalid JSON' }, 400, origin);
|
||||
}
|
||||
|
||||
const { pseudo, score, seed_date } = body;
|
||||
|
||||
// Validation basique
|
||||
if (!pseudo || typeof score !== 'number' || !seed_date) {
|
||||
return jsonResponse({ error: 'Missing fields: pseudo, score, seed_date' }, 400, origin);
|
||||
}
|
||||
if (pseudo.length > 20 || pseudo.length < 1) {
|
||||
return jsonResponse({ error: 'Pseudo must be 1–20 characters' }, 400, origin);
|
||||
}
|
||||
if (score < 0 || score > 99999) {
|
||||
return jsonResponse({ error: 'Invalid score' }, 400, origin);
|
||||
}
|
||||
|
||||
// Vérifier que la date correspond bien à aujourd'hui
|
||||
const today = todayUTC();
|
||||
if (seed_date !== today) {
|
||||
return jsonResponse({ error: 'Cette seed n\'est plus valide (nouvelle journée)' }, 403, origin);
|
||||
}
|
||||
|
||||
// Anti-triche : vérifier si l'IP a déjà joué aujourd'hui
|
||||
const ip = getIP(request);
|
||||
const ipHash = await hashIP(ip);
|
||||
const playedKey = `played:${today}:${ipHash}`;
|
||||
const alreadyPlayed = await env.QUIZ_DATA.get(playedKey);
|
||||
|
||||
if (alreadyPlayed !== null) {
|
||||
return jsonResponse({ error: 'Vous avez déjà joué aujourd\'hui !', already_played: true }, 403, origin);
|
||||
}
|
||||
|
||||
// Marquer l'IP comme ayant joué (expire dans 30h pour couvrir les fuseaux horaires)
|
||||
await env.QUIZ_DATA.put(playedKey, '1', { expirationTtl: 30 * 3600 });
|
||||
|
||||
// Lire le leaderboard existant
|
||||
const lbKey = `scores:${today}`;
|
||||
const existing = await env.QUIZ_DATA.get(lbKey);
|
||||
const scores = existing ? JSON.parse(existing) : [];
|
||||
|
||||
// Ajouter le nouveau score
|
||||
scores.push({
|
||||
pseudo: pseudo.trim().replace(/[<>]/g, ''), // échapper HTML basique
|
||||
score,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Trier par score croissant (le plus bas gagne), garder top 100
|
||||
scores.sort((a, b) => a.score - b.score);
|
||||
const top100 = scores.slice(0, 100);
|
||||
|
||||
// Sauvegarder (expire dans 48h)
|
||||
await env.QUIZ_DATA.put(lbKey, JSON.stringify(top100), { expirationTtl: 48 * 3600 });
|
||||
|
||||
// Trouver le rang du joueur
|
||||
const rank = top100.findIndex(s => s.pseudo === pseudo.trim() && s.score === score) + 1;
|
||||
|
||||
return jsonResponse({ success: true, rank, total: top100.length }, 200, origin);
|
||||
}
|
||||
|
||||
// ── GET /api/leaderboard ──────────────────────────────────────────────────
|
||||
if (path === '/api/leaderboard' && request.method === 'GET') {
|
||||
const date = url.searchParams.get('date') || todayUTC();
|
||||
const lbKey = `scores:${date}`;
|
||||
const data = await env.QUIZ_DATA.get(lbKey);
|
||||
const scores = data ? JSON.parse(data) : [];
|
||||
|
||||
// On retourne top 20 uniquement pour l'affichage
|
||||
return jsonResponse({
|
||||
date,
|
||||
scores: scores.slice(0, 20),
|
||||
total: scores.length,
|
||||
}, 200, origin);
|
||||
}
|
||||
|
||||
// ── 404 ───────────────────────────────────────────────────────────────────
|
||||
return jsonResponse({ error: 'Not found' }, 404, origin);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import worker from '../src';
|
||||
|
||||
describe('Hello World worker', () => {
|
||||
it('responds with Hello World! (unit style)', async () => {
|
||||
const request = new Request('http://example.com');
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
|
||||
it('responds with Hello World! (integration style)', async () => {
|
||||
const response = await SELF.fetch('http://example.com');
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.jsonc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "legrandgeoquiz-api",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-02-25",
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "QUIZ_DATA",
|
||||
"id": "3ee9d1240956477c809a0bb1b15205dc"
|
||||
}
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
BIN
ressource.xlsx
BIN
ressource.xlsx
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue