diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5d1e7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.wrangler/ +*.env +.dev.vars diff --git a/README.md b/README.md index e6af1f5..513f194 100644 --- a/README.md +++ b/README.md @@ -1 +1,160 @@ -# Buildotheque \ No newline at end of file +# Buildotheque + +A Cloudflare Workers backend for storing and searching game builds, with Discord OAuth2 authentication. + +## Features + +- Store and retrieve builds (JSON objects with `nom`, `description`, `auteur`, `tags`, `encoded`, `likes`, `timestamp`) +- Search builds by text (matches `nom`, `description`, or `auteur`) and by tags (cumulative – the build must have **all** requested tags) +- Discord OAuth2 login +- JWT-based session management +- Full CRUD for builds (create, read, update, delete) +- Like system + +--- + +## Setup + +### 1. Install dependencies + +```bash +npm install +``` + +### 2. Create KV namespaces + +```bash +npx wrangler kv namespace create BUILDS_KV +npx wrangler kv namespace create BUILDS_KV --preview +``` + +Copy the generated IDs into `wrangler.toml`. + +### 3. Configure environment variables + +Edit `wrangler.toml` and set: + +| Variable | Description | +|---|---| +| `DISCORD_CLIENT_ID` | Your Discord application's Client ID | +| `DISCORD_REDIRECT_URI` | OAuth2 redirect URI (e.g. `https://.workers.dev/auth/discord/callback`) | +| `FRONTEND_URL` | URL to redirect users to after login (token is appended as `?token=...`) | + +Set secrets (never commit these): + +```bash +npx wrangler secret put DISCORD_CLIENT_SECRET +npx wrangler secret put JWT_SECRET +``` + +### 4. Local development + +```bash +npm run dev +``` + +### 5. Deploy + +```bash +npm run deploy +``` + +--- + +## API Reference + +### Authentication + +#### `GET /auth/discord` +Redirects the user to Discord's OAuth2 authorization page. + +Query params: +- `state` *(optional)* – forwarded back after login + +#### `GET /auth/discord/callback` +Handles the Discord OAuth2 callback. On success, redirects to `FRONTEND_URL?token=`. + +#### `GET /auth/me` +Returns the authenticated user's profile. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ "id": "discord_user_id", "username": "Username", "avatar": "avatar_hash" } +``` + +--- + +### Builds + +#### `GET /builds` +Search and list builds. + +Query params: +- `text` *(optional)* – text matched against `nom`, `description`, `auteur` (case-insensitive) +- `tags` *(optional)* – comma-separated tag IDs; builds must contain **all** of them +- `limit` *(optional)* – max results to return (default: 50, max: 200) +- `offset` *(optional)* – results to skip for pagination (default: 0) + +**Response:** +```json +{ "builds": [...], "total": 42, "limit": 50, "offset": 0 } +``` + +#### `POST /builds` *(auth required)* +Create a new build. + +**Body:** +```json +{ + "nom": "My Build", + "description": "A great build", + "auteur": "MyUsername", + "tags": ["dps", "pvp"], + "encoded": "base64encodedstring" +} +``` + +**Field limits:** +| Field | Max length | Notes | +|---|---|---| +| `nom` | 25 chars | Required | +| `description` | 250 chars | Required | +| `auteur` | 25 chars | Optional – defaults to `Anonymous` | +| `encoded` | 8000 chars | Required | +| `tags` | 5 items, each ≤ 25 chars | Optional | + +#### `GET /builds/:id` +Retrieve a single build by ID. + +#### `PUT /builds/:id` *(auth required, owner only)* +Update a build. All fields are optional. Same limits as POST apply. + +#### `DELETE /builds/:id` *(auth required, owner only)* +Delete a build. + +#### `POST /builds/:id/like` *(auth required)* +Toggle the like on a build. +- First call: adds a like (one like per user per build) +- Second call: removes the like + +**Response:** `{ "build": { ... }, "liked": true }` + +--- + +## Build Object Schema + +```json +{ + "id": "uuid", + "nom": "string", + "description": "string", + "auteur": "string", + "auteurId": "string", + "tags": ["string"], + "encoded": "string", + "likes": 0, + "timestamp": 1712345678000 +} +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9449249 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1612 @@ +{ + "name": "buildotheque", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "buildotheque", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "hono": "^4.12.12", + "jose": "^6.2.2", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260405.1", + "typescript": "^6.0.2", + "wrangler": "^4.80.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260401.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260401.1.tgz", + "integrity": "sha512-ZSmceM70jH6k+/62VkEcmMNzrpr4kSctkX5Lsgqv38KktfhPY/hsh75y1lRoPWS3H3kgMa4p2pUSlidZR1u2hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260401.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260401.1.tgz", + "integrity": "sha512-7UKWF+IUZ3NXMVPsDg8Cjg0r58b+uYlfvs5Yt8bvtU+geCtW4P2MxRHmRSEo8SryckXOJjb/b8tcncgCykFu8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260401.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260401.1.tgz", + "integrity": "sha512-MDWUH/0bvL/l9aauN8zEddyYOXId1OueqrUCXXENNJ95R/lSmF6OgGVuXaYhoIhxQkNiEJ/0NOlnVYj9mJq4dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260401.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260401.1.tgz", + "integrity": "sha512-UgkzpMzVWM/bwbo3vjCTg2aoKfGcUhiEoQoDdo6RGWvbHRJyLVZ4VQCG9ZcISiztkiS2ICCoYOtPy6M/lV6Gcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260401.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260401.1.tgz", + "integrity": "sha512-HBLzcQF5iF4Qv20tQ++pG7xs3OsCnaIbc+GAi6fmhUKZhvmzvml/jwrQzLJ+MPm0cQo41K5OO/U3T4S8tvJetQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260405.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260405.1.tgz", + "integrity": "sha512-PokTmySa+D6MY01R1UfYH48korsN462NK/fl3aw47Hg7XuLuSo/RTpjT0vtWaJhJoFY5tHGOBBIbDcIc8wltLg==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260401.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260401.0.tgz", + "integrity": "sha512-lngHPzZFN9sxYG/mhzvnWiBMNVAN5MsO/7g32ttJ07rymtiK/ZBalODTKb8Od+BQdlU5DOR4CjVt9NydjnUyYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.4", + "workerd": "1.20260401.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/workerd": { + "version": "1.20260401.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260401.1.tgz", + "integrity": "sha512-mUYCd+ohaWJWF5nhDzxugWaAD/DM8Dw0ze3B7bu8JaA7S70+XQJXcvcvwE8C4qGcxSdCyqjsrFzqxKubECDwzg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260401.1", + "@cloudflare/workerd-darwin-arm64": "1.20260401.1", + "@cloudflare/workerd-linux-64": "1.20260401.1", + "@cloudflare/workerd-linux-arm64": "1.20260401.1", + "@cloudflare/workerd-windows-64": "1.20260401.1" + } + }, + "node_modules/wrangler": { + "version": "4.80.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.80.0.tgz", + "integrity": "sha512-2ZKF7uPeOZy65BGk3YfvqBCPo/xH1MrAlMmH9mVP+tCNBrTUMnwOHSj1HrZHgR8LttkAqhko0fGz+I4ax1rzyQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260401.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260401.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260401.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f41708 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "buildotheque", + "version": "1.0.0", + "description": "Cloudflare Workers backend for storing and searching game builds", + "main": "src/index.ts", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "build": "tsc --noEmit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/joint-task-french/Buildotheque.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/joint-task-french/Buildotheque/issues" + }, + "homepage": "https://github.com/joint-task-french/Buildotheque#readme", + "dependencies": { + "hono": "^4.12.12", + "jose": "^6.2.2", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260405.1", + "typescript": "^6.0.2", + "wrangler": "^4.80.0" + } +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..9cae3bc --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,121 @@ +import { SignJWT, jwtVerify } from 'jose'; +import type { Context } from 'hono'; +import type { Env, JWTPayload, DiscordTokenResponse, DiscordUser } from './types'; + +const DISCORD_API = 'https://discord.com/api/v10'; +const TOKEN_EXPIRY = '7d'; + +/** Encode the JWT secret as a CryptoKey. */ +async function getJwtKey(secret: string): Promise { + const encoded = new TextEncoder().encode(secret); + return crypto.subtle.importKey('raw', encoded, { name: 'HMAC', hash: 'SHA-256' }, false, [ + 'sign', + 'verify', + ]); +} + +/** Create a signed JWT for a Discord user. */ +export async function createJWT(user: DiscordUser, secret: string): Promise { + const key = await getJwtKey(secret); + const payload: JWTPayload = { + sub: user.id, + username: user.global_name ?? user.username, + avatar: user.avatar ?? undefined, + }; + return new SignJWT({ ...payload }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(TOKEN_EXPIRY) + .sign(key); +} + +/** Verify a JWT and return its payload, or null if invalid. */ +export async function verifyJWT(token: string, secret: string): Promise { + try { + const key = await getJwtKey(secret); + const { payload } = await jwtVerify(token, key); + return payload as unknown as JWTPayload; + } catch { + return null; + } +} + +/** Extract the Bearer token from the Authorization header. */ +function extractBearerToken(authHeader: string | null | undefined): string | null { + if (!authHeader) return null; + const match = authHeader.match(/^Bearer\s+(.+)$/i); + return match ? match[1] : null; +} + +/** Middleware: attach authenticated user to context variables if a valid JWT is present. */ +export async function authMiddleware( + c: Context<{ Bindings: Env; Variables: { user?: JWTPayload } }>, + next: () => Promise, +): Promise { + const token = extractBearerToken(c.req.header('Authorization')); + if (token) { + const payload = await verifyJWT(token, c.env.JWT_SECRET); + if (payload) { + c.set('user', payload); + } + } + await next(); +} + +/** Middleware: require authentication, return 401 if not authenticated. */ +export async function requireAuth( + c: Context<{ Bindings: Env; Variables: { user?: JWTPayload } }>, + next: () => Promise, +): Promise { + const token = extractBearerToken(c.req.header('Authorization')); + if (!token) { + return c.json({ error: 'Authentification requise' }, 401); + } + const payload = await verifyJWT(token, c.env.JWT_SECRET); + if (!payload) { + return c.json({ error: 'Token invalide ou expiré' }, 401); + } + c.set('user', payload); + await next(); +} + +/** Exchange an OAuth2 code for a Discord access token. */ +export async function exchangeDiscordCode( + code: string, + env: Env, +): Promise { + const body = new URLSearchParams({ + client_id: env.DISCORD_CLIENT_ID, + client_secret: env.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: env.DISCORD_REDIRECT_URI, + }); + + const response = await fetch(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Discord token exchange failed: ${error}`); + } + + return response.json() as Promise; +} + +/** Fetch the authenticated Discord user using an access token. */ +export async function fetchDiscordUser(accessToken: string): Promise { + const response = await fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to fetch Discord user: ${error}`); + } + + return response.json() as Promise; +} diff --git a/src/builds.ts b/src/builds.ts new file mode 100644 index 0000000..e1cd36b --- /dev/null +++ b/src/builds.ts @@ -0,0 +1,211 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { Build, BuildInput, Env } from './types'; + +/** Key used to store the list of all build IDs in KV. */ +const BUILD_INDEX_KEY = 'build_index'; + +/** KV key for a build by its ID. */ +function buildKey(id: string): string { + return `build:${id}`; +} + +/** KV key for the set of user IDs who liked a build. */ +function likesKey(id: string): string { + return `likes:${id}`; +} + +/** Retrieve all build IDs from the index. */ +async function getBuildIndex(kv: KVNamespace): Promise { + const raw = await kv.get(BUILD_INDEX_KEY); + if (!raw) return []; + return JSON.parse(raw) as string[]; +} + +/** Persist the list of build IDs to the index. */ +async function saveBuildIndex(kv: KVNamespace, ids: string[]): Promise { + await kv.put(BUILD_INDEX_KEY, JSON.stringify(ids)); +} + +/** Retrieve the set of user IDs who liked a build. */ +async function getLikers(id: string, kv: KVNamespace): Promise> { + const raw = await kv.get(likesKey(id)); + if (!raw) return new Set(); + return new Set(JSON.parse(raw) as string[]); +} + +/** Persist the set of user IDs who liked a build. */ +async function saveLikers(id: string, likers: Set, kv: KVNamespace): Promise { + await kv.put(likesKey(id), JSON.stringify([...likers])); +} + +/** Create a new build and return it. + * + * The `auteur` display name is taken from `input.auteur` when provided; + * otherwise it defaults to "Anonymous". + * + * NOTE: The index update (read-modify-write) is not atomic; under heavy + * concurrent writes, entries could be lost. For a high-concurrency + * production deployment, replace with Cloudflare Durable Objects or + * use KV `list()` with a shared key prefix instead of a manual index. + */ +export async function createBuild( + input: BuildInput, + authorId: string, + env: Env, +): Promise { + const id = uuidv4(); + const build: Build = { + id, + nom: input.nom, + description: input.description, + auteur: input.auteur?.trim() || 'Anonymous', + auteurId: authorId, + tags: input.tags ?? [], + encoded: input.encoded, + likes: 0, + timestamp: Date.now(), + }; + + // Store the build document first so it is always addressable. + await env.BUILDS_KV.put(buildKey(id), JSON.stringify(build)); + + // Update the shared index (non-atomic – see note above). + const index = await getBuildIndex(env.BUILDS_KV); + index.push(id); + await saveBuildIndex(env.BUILDS_KV, index); + + return build; +} + +/** Retrieve a single build by ID, or null if not found. */ +export async function getBuild(id: string, env: Env): Promise { + const raw = await env.BUILDS_KV.get(buildKey(id)); + if (!raw) return null; + return JSON.parse(raw) as Build; +} + +/** Update a build's mutable fields. Returns the updated build or null if not found. */ +export async function updateBuild( + id: string, + input: Partial, + env: Env, +): Promise { + const existing = await getBuild(id, env); + if (!existing) return null; + + const updated: Build = { + ...existing, + nom: input.nom ?? existing.nom, + description: input.description ?? existing.description, + auteur: input.auteur !== undefined ? (input.auteur.trim() || existing.auteur) : existing.auteur, + tags: input.tags ?? existing.tags, + encoded: input.encoded ?? existing.encoded, + }; + + await env.BUILDS_KV.put(buildKey(id), JSON.stringify(updated)); + return updated; +} + +/** Delete a build by ID. Returns true if deleted, false if not found. */ +export async function deleteBuild(id: string, env: Env): Promise { + const existing = await getBuild(id, env); + if (!existing) return false; + + await env.BUILDS_KV.delete(buildKey(id)); + await env.BUILDS_KV.delete(likesKey(id)); + + const index = await getBuildIndex(env.BUILDS_KV); + const newIndex = index.filter((i) => i !== id); + await saveBuildIndex(env.BUILDS_KV, newIndex); + + return true; +} + +/** Toggle the like on a build for a given user. + * + * - If the user has not yet liked the build, their ID is added and the like + * count is incremented. + * - If the user has already liked the build, their ID is removed and the like + * count is decremented. + * + * Returns the updated build and whether the user now likes it, or null if the + * build was not found. + * + * NOTE: This is a non-atomic read-modify-write; under heavy concurrent + * requests, some updates may be lost. For strict accuracy, migrate to + * Cloudflare Durable Objects with transactional storage. + */ +export async function toggleLike( + id: string, + userId: string, + env: Env, +): Promise<{ build: Build; liked: boolean } | null> { + const existing = await getBuild(id, env); + if (!existing) return null; + + const likers = await getLikers(id, env.BUILDS_KV); + const alreadyLiked = likers.has(userId); + + if (alreadyLiked) { + likers.delete(userId); + } else { + likers.add(userId); + } + + const liked = !alreadyLiked; + const build: Build = { ...existing, likes: likers.size }; + + await env.BUILDS_KV.put(buildKey(id), JSON.stringify(build)); + await saveLikers(id, likers, env.BUILDS_KV); + + return { build, liked }; +} + +/** Search builds by optional text and tags. + * + * - `text` : matches against nom, description, or auteur (case-insensitive) + * - `tags` : array of tag IDs; the build must contain ALL of them + * - `limit` : maximum number of results to return (default: 50) + * - `offset`: number of results to skip for pagination (default: 0) + * + * NOTE: All build objects are fetched sequentially from KV to apply + * filters. This is acceptable for small datasets. For large libraries, + * consider Cloudflare D1 or maintaining separate tag-keyed indexes in KV. + */ +export async function searchBuilds( + env: Env, + text?: string, + tags?: string[], + limit = 50, + offset = 0, +): Promise<{ builds: Build[]; total: number }> { + const index = await getBuildIndex(env.BUILDS_KV); + + const matched: Build[] = []; + const lowerText = text?.toLowerCase().trim(); + + for (const id of index) { + const build = await getBuild(id, env); + if (!build) continue; + + // Tag filter: build must contain ALL requested tags + if (tags && tags.length > 0) { + const hasAllTags = tags.every((tag) => build.tags.includes(tag)); + if (!hasAllTags) continue; + } + + // Text filter: matches nom, description, or auteur + if (lowerText) { + const inNom = build.nom.toLowerCase().includes(lowerText); + const inDescription = build.description.toLowerCase().includes(lowerText); + const inAuteur = build.auteur.toLowerCase().includes(lowerText); + if (!inNom && !inDescription && !inAuteur) continue; + } + + matched.push(build); + } + + const total = matched.length; + const builds = matched.slice(offset, offset + limit); + return { builds, total }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a55fea6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,378 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import type { Env, JWTPayload } from './types'; +import { + authMiddleware, + requireAuth, + createJWT, + exchangeDiscordCode, + fetchDiscordUser, +} from './auth'; +import { + createBuild, + getBuild, + updateBuild, + deleteBuild, + toggleLike, + searchBuilds, +} from './builds'; + +type Variables = { user?: JWTPayload }; + +const app = new Hono<{ Bindings: Env; Variables: Variables }>(); + +// --------------------------------------------------------------------------- +// CORS – allow requests from the configured frontend URL +// --------------------------------------------------------------------------- +app.use('*', async (c, next) => { + const frontendUrl = c.env.FRONTEND_URL ?? ''; + return cors({ + origin: frontendUrl || '*', + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + maxAge: 86400, + })(c, next); +}); + +// Attach user from JWT when present (non-blocking) +app.use('*', authMiddleware); + +// --------------------------------------------------------------------------- +// Health check +// --------------------------------------------------------------------------- +app.get('/', (c) => c.json({ status: 'ok', name: 'Buildotheque API' })); + +// --------------------------------------------------------------------------- +// Auth – Discord OAuth2 +// --------------------------------------------------------------------------- + +/** + * GET /auth/discord + * Redirect the user to Discord's OAuth2 authorization page. + * Query param `state` is optional and will be forwarded back after login. + */ +app.get('/auth/discord', (c) => { + const state = c.req.query('state') ?? ''; + const params = new URLSearchParams({ + client_id: c.env.DISCORD_CLIENT_ID, + redirect_uri: c.env.DISCORD_REDIRECT_URI, + response_type: 'code', + scope: 'identify', + state, + }); + return c.redirect(`https://discord.com/api/oauth2/authorize?${params.toString()}`); +}); + +/** + * GET /auth/discord/callback + * Handle the OAuth2 callback from Discord, create a JWT, and redirect to the frontend. + */ +app.get('/auth/discord/callback', async (c) => { + const code = c.req.query('code'); + const state = c.req.query('state') ?? ''; + + if (!code) { + return c.json({ error: 'Code OAuth manquant' }, 400); + } + + try { + const tokenResponse = await exchangeDiscordCode(code, c.env); + const discordUser = await fetchDiscordUser(tokenResponse.access_token); + const jwt = await createJWT(discordUser, c.env.JWT_SECRET); + + // Redirect to frontend with the token as a query param. + const redirectUrl = new URL(c.env.FRONTEND_URL); + redirectUrl.searchParams.set('token', jwt); + if (state) redirectUrl.searchParams.set('state', state); + + return c.redirect(redirectUrl.toString()); + } catch (err) { + console.error('Discord callback error:', err); + return c.json({ error: 'Erreur lors de la connexion avec Discord' }, 500); + } +}); + +/** + * GET /auth/me + * Return the currently authenticated user's profile. + */ +app.get('/auth/me', requireAuth, (c) => { + const user = c.get('user') as JWTPayload; + return c.json({ id: user.sub, username: user.username, avatar: user.avatar ?? null }); +}); + +// --------------------------------------------------------------------------- +// Builds – CRUD + search +// --------------------------------------------------------------------------- + +/** + * GET /builds + * Search and list builds. + * + * Query params: + * - `text` : search text matched against nom, description, auteur + * - `tags` : comma-separated list of tag IDs (ALL must be present on the build) + * - `limit` : max number of results (default: 50, max: 200) + * - `offset` : number of results to skip (default: 0) + */ +app.get('/builds', async (c) => { + const text = c.req.query('text') ?? undefined; + const tagsParam = c.req.query('tags'); + const tags = tagsParam ? tagsParam.split(',').map((t) => t.trim()).filter(Boolean) : undefined; + + const rawLimit = parseInt(c.req.query('limit') ?? '50', 10); + const rawOffset = parseInt(c.req.query('offset') ?? '0', 10); + const limit = Number.isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 200); + const offset = Number.isNaN(rawOffset) || rawOffset < 0 ? 0 : rawOffset; + + const { builds, total } = await searchBuilds(c.env, text, tags, limit, offset); + return c.json({ builds, total, limit, offset }); +}); + +/** + * POST /builds + * Create a new build. Requires authentication. + * + * Body: { nom, description, auteur?, tags?, encoded } + * + * Limits: + * - nom : ≤ 25 characters + * - description : ≤ 250 characters + * - auteur : ≤ 25 characters (optional; defaults to Discord username) + * - tags : ≤ 5 items, each ≤ 25 characters + * - encoded : ≤ 8000 characters + */ +app.post('/builds', requireAuth, async (c) => { + const user = c.get('user') as JWTPayload; + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Corps de requête JSON invalide' }, 400); + } + + if ( + typeof body !== 'object' || + body === null || + !('nom' in body) || + !('description' in body) || + !('encoded' in body) + ) { + return c.json({ error: 'Champs requis manquants : nom, description, encoded' }, 400); + } + + const input = body as { + nom: unknown; + description: unknown; + auteur?: unknown; + tags?: unknown; + encoded: unknown; + }; + + if (typeof input.nom !== 'string' || input.nom.trim() === '') { + return c.json({ error: 'Le champ "nom" doit être une chaîne non vide' }, 400); + } + if (input.nom.length > 25) { + return c.json({ error: 'Le champ "nom" ne peut pas dépasser 25 caractères' }, 400); + } + if (typeof input.description !== 'string') { + return c.json({ error: 'Le champ "description" doit être une chaîne' }, 400); + } + if (input.description.length > 250) { + return c.json({ error: 'Le champ "description" ne peut pas dépasser 250 caractères' }, 400); + } + if (input.auteur !== undefined) { + if (typeof input.auteur !== 'string' || input.auteur.trim() === '') { + return c.json({ error: 'Le champ "auteur" doit être une chaîne non vide' }, 400); + } + if (input.auteur.length > 25) { + return c.json({ error: 'Le champ "auteur" ne peut pas dépasser 25 caractères' }, 400); + } + } + if (typeof input.encoded !== 'string' || input.encoded.trim() === '') { + return c.json({ error: 'Le champ "encoded" doit être une chaîne non vide' }, 400); + } + if (input.encoded.length > 8000) { + return c.json({ error: 'Le champ "encoded" ne peut pas dépasser 8000 caractères' }, 400); + } + if (input.tags !== undefined) { + if (!Array.isArray(input.tags)) { + return c.json({ error: 'Le champ "tags" doit être un tableau' }, 400); + } + if (input.tags.length > 5) { + return c.json({ error: 'Maximum 5 tags autorisés' }, 400); + } + for (const tag of input.tags) { + if (typeof tag !== 'string') { + return c.json({ error: 'Chaque tag doit être une chaîne de caractères' }, 400); + } + if (tag.length > 25) { + return c.json({ error: 'Chaque tag ne peut pas dépasser 25 caractères' }, 400); + } + } + } + + const build = await createBuild( + { + nom: input.nom, + description: input.description, + auteur: typeof input.auteur === 'string' ? input.auteur : undefined, + tags: Array.isArray(input.tags) ? (input.tags as string[]) : undefined, + encoded: input.encoded, + }, + user.sub, + c.env, + ); + + return c.json(build, 201); +}); + +/** + * GET /builds/:id + * Retrieve a single build by ID. + */ +app.get('/builds/:id', async (c) => { + const id = c.req.param('id') ?? ''; + const build = await getBuild(id, c.env); + if (!build) return c.json({ error: 'Build introuvable' }, 404); + return c.json(build); +}); + +/** + * PUT /builds/:id + * Update a build. Requires authentication and ownership. + * + * Body: { nom?, description?, auteur?, tags?, encoded? } + * + * Same character/count limits as POST apply to any supplied field. + */ +app.put('/builds/:id', requireAuth, async (c) => { + const user = c.get('user') as JWTPayload; + const id = c.req.param('id') ?? ''; + + const existing = await getBuild(id, c.env); + if (!existing) return c.json({ error: 'Build introuvable' }, 404); + if (existing.auteurId !== user.sub) { + return c.json({ error: 'Non autorisé : vous n\'êtes pas le propriétaire de ce build' }, 403); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Corps de requête JSON invalide' }, 400); + } + + const input = body as { + nom?: unknown; + description?: unknown; + auteur?: unknown; + tags?: unknown; + encoded?: unknown; + }; + + if (input.nom !== undefined) { + if (typeof input.nom !== 'string' || input.nom.trim() === '') { + return c.json({ error: 'Le champ "nom" doit être une chaîne non vide' }, 400); + } + if (input.nom.length > 25) { + return c.json({ error: 'Le champ "nom" ne peut pas dépasser 25 caractères' }, 400); + } + } + if (input.description !== undefined) { + if (typeof input.description !== 'string') { + return c.json({ error: 'Le champ "description" doit être une chaîne' }, 400); + } + if (input.description.length > 250) { + return c.json({ error: 'Le champ "description" ne peut pas dépasser 250 caractères' }, 400); + } + } + if (input.auteur !== undefined) { + if (typeof input.auteur !== 'string' || input.auteur.trim() === '') { + return c.json({ error: 'Le champ "auteur" doit être une chaîne non vide' }, 400); + } + if (input.auteur.length > 25) { + return c.json({ error: 'Le champ "auteur" ne peut pas dépasser 25 caractères' }, 400); + } + } + if (input.encoded !== undefined) { + if (typeof input.encoded !== 'string' || input.encoded.trim() === '') { + return c.json({ error: 'Le champ "encoded" doit être une chaîne non vide' }, 400); + } + if (input.encoded.length > 8000) { + return c.json({ error: 'Le champ "encoded" ne peut pas dépasser 8000 caractères' }, 400); + } + } + if (input.tags !== undefined) { + if (!Array.isArray(input.tags)) { + return c.json({ error: 'Le champ "tags" doit être un tableau' }, 400); + } + if (input.tags.length > 5) { + return c.json({ error: 'Maximum 5 tags autorisés' }, 400); + } + for (const tag of input.tags) { + if (typeof tag !== 'string') { + return c.json({ error: 'Chaque tag doit être une chaîne de caractères' }, 400); + } + if (tag.length > 25) { + return c.json({ error: 'Chaque tag ne peut pas dépasser 25 caractères' }, 400); + } + } + } + + const updated = await updateBuild( + id, + { + nom: typeof input.nom === 'string' ? input.nom : undefined, + description: typeof input.description === 'string' ? input.description : undefined, + auteur: typeof input.auteur === 'string' ? input.auteur : undefined, + tags: Array.isArray(input.tags) ? (input.tags as string[]) : undefined, + encoded: typeof input.encoded === 'string' ? input.encoded : undefined, + }, + c.env, + ); + + return c.json(updated); +}); + +/** + * DELETE /builds/:id + * Delete a build. Requires authentication and ownership. + */ +app.delete('/builds/:id', requireAuth, async (c) => { + const user = c.get('user') as JWTPayload; + const id = c.req.param('id') ?? ''; + + const existing = await getBuild(id, c.env); + if (!existing) return c.json({ error: 'Build introuvable' }, 404); + if (existing.auteurId !== user.sub) { + return c.json({ error: 'Non autorisé : vous n\'êtes pas le propriétaire de ce build' }, 403); + } + + await deleteBuild(id, c.env); + return c.json({ message: 'Build supprimé avec succès' }); +}); + +/** + * POST /builds/:id/like + * Toggle the like on a build for the authenticated user. + * - First call: adds a like (each user can like a build only once) + * - Second call: removes the like + * + * Returns: { build, liked } where `liked` indicates the new like state. + */ +app.post('/builds/:id/like', requireAuth, async (c) => { + const user = c.get('user') as JWTPayload; + const id = c.req.param('id') ?? ''; + const result = await toggleLike(id, user.sub, c.env); + if (!result) return c.json({ error: 'Build introuvable' }, 404); + return c.json(result); +}); + +// --------------------------------------------------------------------------- +// 404 fallback +// --------------------------------------------------------------------------- +app.notFound((c) => c.json({ error: 'Route introuvable' }, 404)); + +export default app; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..466a592 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,72 @@ +/** + * TypeScript types for the Buildotheque API. + */ + +/** A build object stored in KV. */ +export interface Build { + /** Unique identifier for the build. */ + id: string; + /** Name of the build. */ + nom: string; + /** Detailed description of the build. */ + description: string; + /** Author (Discord username) of the build. */ + auteur: string; + /** Discord user ID of the author. */ + auteurId: string; + /** List of tag identifiers associated with the build. */ + tags: string[]; + /** Encoded string containing the build configuration. */ + encoded: string; + /** Number of likes. */ + likes: number; + /** Creation timestamp (Unix ms). */ + timestamp: number; +} + +/** Input payload for creating or updating a build. */ +export interface BuildInput { + nom: string; + description: string; + /** Display name chosen by the author (≤ 25 characters). */ + auteur?: string; + tags?: string[]; + encoded: string; +} + +/** Discord OAuth token response. */ +export interface DiscordTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} + +/** Discord user object returned from the API. */ +export interface DiscordUser { + id: string; + username: string; + discriminator: string; + global_name?: string | null; + avatar?: string | null; +} + +/** JWT payload stored in session tokens. */ +export interface JWTPayload { + sub: string; // Discord user ID + username: string; // Discord username + avatar?: string; // Discord avatar hash + iat?: number; + exp?: number; +} + +/** Cloudflare Workers environment bindings. */ +export interface Env { + BUILDS_KV: KVNamespace; + DISCORD_CLIENT_ID: string; + DISCORD_CLIENT_SECRET: string; + DISCORD_REDIRECT_URI: string; + JWT_SECRET: string; + FRONTEND_URL: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..743be10 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..5fc08cb --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,18 @@ +name = "buildotheque" +main = "src/index.ts" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] + +[[kv_namespaces]] +binding = "BUILDS_KV" +id = "REPLACE_WITH_YOUR_BUILDS_KV_ID" +preview_id = "REPLACE_WITH_YOUR_BUILDS_KV_PREVIEW_ID" + +[vars] +DISCORD_CLIENT_ID = "YOUR_DISCORD_CLIENT_ID" +DISCORD_REDIRECT_URI = "https://buildotheque.YOUR_SUBDOMAIN.workers.dev/auth/discord/callback" +FRONTEND_URL = "https://your-frontend-url.com" + +# Secrets (set via `wrangler secret put`): +# DISCORD_CLIENT_SECRET - Your Discord OAuth2 client secret +# JWT_SECRET - A strong random secret for signing JWTs