diff --git a/.gitignore b/.gitignore index 9873d6e..d5a1137 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ database.sqlite .env -npm-debug.log* +target/ +.quarkus/ +.m2/ diff --git a/README.md b/README.md index 0948767..307c067 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,48 @@ -# Linux Distro Roguelite Promotion API +# Linux Distro Roguelite Promotion -REST-API fuer eine Pre-Register-Page eines kostenlosen Games. Spieler registrieren sich mit Benutzername und E-Mail, waehlen Plattformen aus und schalten ueber Community-Meilensteine Belohnungen frei. +Quarkus-Backend und statische Pre-Register-Page fuer ein kostenloses Game. Spieler registrieren sich mit Benutzername und E-Mail, waehlen Plattformen aus und schalten ueber Community-Meilensteine Belohnungen frei. -## Installation +## Voraussetzungen -```bash -npm install -``` +- JDK 21 +- Maven 3.9+ oder Quarkus CLI +- PostgreSQL 16+ oder Docker ## Start +PostgreSQL lokal mit Docker starten: + ```bash -ADMIN_KEY=secret npm start +docker compose up -d postgres +``` + +```bash +ADMIN_USERNAME=admin ADMIN_PASSWORD=secret mvn quarkus:dev ``` Unter PowerShell: ```powershell -$env:ADMIN_KEY = "secret" -npm start +$env:ADMIN_USERNAME = "admin" +$env:ADMIN_PASSWORD = "secret" +$env:DB_URL = "jdbc:postgresql://localhost:5432/linux_distro_roguelite_promotion" +$env:DB_USERNAME = "postgres" +$env:DB_PASSWORD = "postgres" +mvn quarkus:dev ``` Der Server laeuft standardmaessig auf Port `3000`. Mit `PORT` kann der Port ueberschrieben werden. ```powershell $env:PORT = "4000" -$env:ADMIN_KEY = "secret" -npm start +$env:ADMIN_USERNAME = "admin" +$env:ADMIN_PASSWORD = "secret" +mvn quarkus:dev ``` -Beim ersten Start wird `database.sqlite` erstellt und automatisch mit diesen Daten befuellt: +Die Marketingseite ist unter `http://localhost:3000/` erreichbar. Die separate Registrierung liegt unter `http://localhost:3000/register.html`. Beim ersten Start legt das Backend die PostgreSQL-Tabellen automatisch an und befuellt sie mit diesen Daten: -- Plattformen: PC, PlayStation, Xbox, Nintendo Switch, Mobile +- Plattformen: Windows, Linux, Mobile (Android) - Meilensteine: 1,000 und 5,000 Community-Registrierungen - Je eine Beispiel-Belohnung pro Meilenstein @@ -41,14 +52,65 @@ Alle Fehler haben das Format: { "error": "beschreibende Meldung" } ``` +## Build + +```bash +mvn package +java -jar target/quarkus-app/quarkus-run.jar +``` + +## Datenbank + +Die PostgreSQL-Verbindung wird ueber Umgebungsvariablen konfiguriert: + +- `DB_URL`, Standard: `jdbc:postgresql://localhost:5432/linux_distro_roguelite_promotion` +- `DB_USERNAME`, Standard: `postgres` +- `DB_PASSWORD`, Standard: `postgres` + +Das Schema entspricht dem Klassendiagramm: + +- `users`: `id`, `username`, `email`, `newsletter_optin`, `created_at` +- `platforms`: `id`, `name` +- `registrations`: `id`, `user_id`, `platform_id`, `created_at` +- `milestones`: `id`, `title`, `target_count`, `reached` +- `rewards`: `id`, `milestone_id`, `name`, `description`, `image_url` + +Beziehungen: + +- Ein Nutzer hat `0..*` Registrierungen. +- Eine Registrierung gehoert zu genau einem Nutzer und genau einer Plattform. +- Ein Meilenstein hat `0..*` Belohnungen. +- Beim Erstellen einer Registrierung werden erreichte Meilensteine anhand der Gesamtzahl aktualisiert. + ## Endpunkte ### Health Check ```bash -curl http://localhost:3000/ +curl http://localhost:3000/api/health ``` +### Authentifizierung + +`POST /auth/login` prueft registrierte Spieler anhand von Benutzername und E-Mail und erstellt ein signiertes JWT. Fuer Admin-Zugriff kann stattdessen `username` plus `password` aus `ADMIN_USERNAME`/`ADMIN_PASSWORD` verwendet werden. + +```bash +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"tuxrunner\",\"email\":\"tux@example.com\"}" +``` + +`GET /auth/me`, `GET /auth/game-details` und geschuetzte Admin-Endpunkte erwarten ein gueltiges Bearer-Token. + +```bash +curl http://localhost:3000/auth/game-details \ + -H "Authorization: Bearer " +``` + +Die separate Login-Seite ist unter `http://localhost:3000/auth.html` erreichbar. Nach der Registrierung kann man sich dort mit Benutzername und E-Mail anmelden. + +Swagger UI ist unter `http://localhost:3000/q/swagger-ui` verfuegbar und fuer Bearer-JWT konfiguriert. + ### Benutzer `POST /users` erstellt einen neuen vorregistrierten Nutzer. @@ -129,29 +191,29 @@ curl -X DELETE http://localhost:3000/registrations/1 curl http://localhost:3000/milestones ``` -`POST /milestones` erstellt einen Meilenstein. Admin-Key erforderlich. +`POST /milestones` erstellt einen Meilenstein. Admin-JWT erforderlich. ```bash curl -X POST http://localhost:3000/milestones \ -H "Content-Type: application/json" \ - -H "X-Admin-Key: secret" \ + -H "Authorization: Bearer " \ -d "{\"title\":\"10,000 community registrations\",\"target_count\":10000}" ``` -`PUT /milestones/{id}` aktualisiert beliebige Teilfelder. Admin-Key erforderlich. +`PUT /milestones/{id}` aktualisiert beliebige Teilfelder. Admin-JWT erforderlich. ```bash curl -X PUT http://localhost:3000/milestones/1 \ -H "Content-Type: application/json" \ - -H "X-Admin-Key: secret" \ + -H "Authorization: Bearer " \ -d "{\"title\":\"1,000 pre-registrations\",\"reached\":false}" ``` -`DELETE /milestones/{id}` loescht einen Meilenstein und dessen Belohnungen. Admin-Key erforderlich. +`DELETE /milestones/{id}` loescht einen Meilenstein und dessen Belohnungen. Admin-JWT erforderlich. ```bash curl -X DELETE http://localhost:3000/milestones/1 \ - -H "X-Admin-Key: secret" + -H "Authorization: Bearer " ``` ### Belohnungen @@ -168,29 +230,29 @@ curl http://localhost:3000/rewards curl http://localhost:3000/milestones/1/rewards ``` -`POST /rewards` erstellt eine Belohnung. Admin-Key erforderlich. +`POST /rewards` erstellt eine Belohnung. Admin-JWT erforderlich. ```bash curl -X POST http://localhost:3000/rewards \ -H "Content-Type: application/json" \ - -H "X-Admin-Key: secret" \ + -H "Authorization: Bearer " \ -d "{\"milestone_id\":1,\"name\":\"Beta Hat\",\"description\":\"Cosmetic launch hat\",\"image_url\":\"https://example.com/rewards/beta-hat.png\"}" ``` -`PUT /rewards/{id}` aktualisiert beliebige Teilfelder. Admin-Key erforderlich. +`PUT /rewards/{id}` aktualisiert beliebige Teilfelder. Admin-JWT erforderlich. ```bash curl -X PUT http://localhost:3000/rewards/1 \ -H "Content-Type: application/json" \ - -H "X-Admin-Key: secret" \ + -H "Authorization: Bearer " \ -d "{\"description\":\"Updated reward description\"}" ``` -`DELETE /rewards/{id}` loescht eine Belohnung. Admin-Key erforderlich. +`DELETE /rewards/{id}` loescht eine Belohnung. Admin-JWT erforderlich. ```bash curl -X DELETE http://localhost:3000/rewards/1 \ - -H "X-Admin-Key: secret" + -H "Authorization: Bearer " ``` ## Statuscodes @@ -198,6 +260,6 @@ curl -X DELETE http://localhost:3000/rewards/1 \ - `200`: Erfolgreiche Abfrage, Aktualisierung oder Loeschung - `201`: Ressource erstellt - `400`: Ungueltige Eingaben oder nicht existierende Fremdschluessel -- `401`: Fehlender oder falscher Admin-Key +- `401`: Fehlendes, ungueltiges oder abgelaufenes JWT bzw. falsche Login-Daten - `404`: Ressource oder Route nicht gefunden - `409`: Konflikt, z. B. doppelte E-Mail, doppelter Benutzername oder doppelte Nutzer-Plattform-Kombination diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 13a8f86..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1282 +0,0 @@ -{ - "name": "linux-distro-roguelite-promotion", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "linux-distro-roguelite-promotion", - "version": "1.0.0", - "dependencies": { - "better-sqlite3": "^12.11.1", - "cors": "^2.8.5", - "express": "^4.21.2" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "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==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 6bdae19..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "linux-distro-roguelite-promotion", - "version": "1.0.0", - "description": "REST API for a free game pre-register page.", - "main": "src/app.js", - "scripts": { - "start": "node src/app.js", - "dev": "node --watch src/app.js" - }, - "dependencies": { - "better-sqlite3": "^12.11.1", - "cors": "^2.8.5", - "express": "^4.21.2" - } -} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8e4663b --- /dev/null +++ b/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + dev.frederik + linux-distro-roguelite-promotion + 1.0.0-SNAPSHOT + + + 3.14.0 + 21 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.37.1 + true + 3.5.3 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-agroal + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-smallrye-jwt + + + io.quarkus + quarkus-smallrye-jwt-build + + + io.quarkus + quarkus-smallrye-openapi + + + io.quarkus + quarkus-junit + test + + + io.rest-assured + rest-assured + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + + + + + + diff --git a/src/app.js b/src/app.js deleted file mode 100644 index 7672440..0000000 --- a/src/app.js +++ /dev/null @@ -1,45 +0,0 @@ -const express = require("express"); -const cors = require("cors"); -const { initializeDatabase } = require("./db"); - -const usersRouter = require("./routes/users"); -const platformsRouter = require("./routes/platforms"); -const registrationsRouter = require("./routes/registrations"); -const milestonesRouter = require("./routes/milestones"); -const rewardsRouter = require("./routes/rewards"); - -initializeDatabase(); - -const app = express(); - -app.use(cors()); -app.use(express.json()); - -app.get("/", (req, res) => { - res.json({ message: "Pre-registration API is running." }); -}); - -app.use("/users", usersRouter); -app.use("/platforms", platformsRouter); -app.use("/registrations", registrationsRouter); -app.use("/milestones", milestonesRouter); -app.use("/rewards", rewardsRouter); - -app.use((req, res) => { - res.status(404).json({ error: "Route not found." }); -}); - -app.use((err, req, res, next) => { - if (err instanceof SyntaxError && err.status === 400 && "body" in err) { - return res.status(400).json({ error: "Invalid JSON body." }); - } - - console.error(err); - res.status(500).json({ error: "Internal server error." }); -}); - -const port = process.env.PORT || 3000; - -app.listen(port, () => { - console.log(`Pre-registration API listening on port ${port}`); -}); diff --git a/src/db.js b/src/db.js deleted file mode 100644 index 1211992..0000000 --- a/src/db.js +++ /dev/null @@ -1,111 +0,0 @@ -const path = require("path"); -const Database = require("better-sqlite3"); - -const dbPath = path.join(__dirname, "..", "database.sqlite"); -const db = new Database(dbPath); - -db.pragma("foreign_keys = ON"); - -function boolToInt(value) { - return value ? 1 : 0; -} - -function createSchema() { - db.exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - newsletter_optin INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS platforms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL - ); - - CREATE TABLE IF NOT EXISTS registrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - platform_id INTEGER NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (platform_id) REFERENCES platforms(id), - UNIQUE (user_id, platform_id) - ); - - CREATE TABLE IF NOT EXISTS milestones ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - target_count INTEGER NOT NULL, - reached INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS rewards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - milestone_id INTEGER NOT NULL, - name TEXT NOT NULL, - description TEXT, - image_url TEXT, - FOREIGN KEY (milestone_id) REFERENCES milestones(id) ON DELETE CASCADE - ); - `); -} - -function seedDatabase() { - const platforms = ["PC", "PlayStation", "Xbox", "Nintendo Switch", "Mobile"]; - const insertPlatform = db.prepare("INSERT OR IGNORE INTO platforms (name) VALUES (?)"); - platforms.forEach((platform) => insertPlatform.run(platform)); - - // Seed example milestones only when the table is empty to avoid duplicate demo data. - const milestoneCount = db.prepare("SELECT COUNT(*) AS count FROM milestones").get().count; - if (milestoneCount === 0) { - const insertMilestone = db.prepare( - "INSERT INTO milestones (title, target_count, reached) VALUES (?, ?, 0)" - ); - const insertReward = db.prepare( - "INSERT INTO rewards (milestone_id, name, description, image_url) VALUES (?, ?, ?, ?)" - ); - - const first = insertMilestone.run("1,000 community registrations", 1000); - insertReward.run( - first.lastInsertRowid, - "Founder Banner", - "Exclusive in-game profile banner for early supporters.", - "https://example.com/rewards/founder-banner.png" - ); - - const second = insertMilestone.run("5,000 community registrations", 5000); - insertReward.run( - second.lastInsertRowid, - "Launch Loot Crate", - "Free cosmetic crate unlocked for all players at launch.", - "https://example.com/rewards/launch-loot-crate.png" - ); - } -} - -function initializeDatabase() { - createSchema(); - seedDatabase(); -} - -function getRegistrationCount() { - return db.prepare("SELECT COUNT(*) AS count FROM registrations").get().count; -} - -function updateReachedMilestones() { - const currentCount = getRegistrationCount(); - db.prepare( - "UPDATE milestones SET reached = 1 WHERE reached = 0 AND target_count <= ?" - ).run(currentCount); -} - -module.exports = { - db, - initializeDatabase, - getRegistrationCount, - updateReachedMilestones, - boolToInt -}; diff --git a/src/main/java/dev/frederik/promotion/ApiException.java b/src/main/java/dev/frederik/promotion/ApiException.java new file mode 100644 index 0000000..326a7be --- /dev/null +++ b/src/main/java/dev/frederik/promotion/ApiException.java @@ -0,0 +1,14 @@ +package dev.frederik.promotion; + +public class ApiException extends RuntimeException { + private final int status; + + public ApiException(int status, String message) { + super(message); + this.status = status; + } + + public int status() { + return status; + } +} diff --git a/src/main/java/dev/frederik/promotion/ApiExceptionMapper.java b/src/main/java/dev/frederik/promotion/ApiExceptionMapper.java new file mode 100644 index 0000000..97419fd --- /dev/null +++ b/src/main/java/dev/frederik/promotion/ApiExceptionMapper.java @@ -0,0 +1,17 @@ +package dev.frederik.promotion; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.Map; + +@Provider +public class ApiExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(ApiException exception) { + return Response.status(exception.status()) + .entity(Map.of("error", exception.getMessage())) + .build(); + } +} diff --git a/src/main/java/dev/frederik/promotion/AuthResource.java b/src/main/java/dev/frederik/promotion/AuthResource.java new file mode 100644 index 0000000..77c77ed --- /dev/null +++ b/src/main/java/dev/frederik/promotion/AuthResource.java @@ -0,0 +1,144 @@ +package dev.frederik.promotion; + +import com.fasterxml.jackson.databind.JsonNode; +import io.smallrye.jwt.build.Jwt; +import jakarta.annotation.security.RolesAllowed; +import jakarta.annotation.security.PermitAll; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +@Path("/auth") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class AuthResource { + private static final String ISSUER = "https://kernel-panic.local/issuer"; + + private final DatabaseService database; + private final String adminUsername; + private final String adminPassword; + private final JsonWebToken jwt; + + @Inject + public AuthResource( + DatabaseService database, + @ConfigProperty(name = "auth.admin.username") String adminUsername, + @ConfigProperty(name = "auth.admin.password") String adminPassword, + JsonWebToken jwt) { + this.database = database; + this.adminUsername = adminUsername; + this.adminPassword = adminPassword; + this.jwt = jwt; + } + + @POST + @PermitAll + @Path("/login") + public Response login(JsonNode body) { + String username = readText(body, "username", "username is required."); + String password = readOptionalText(body, "password"); + if (password != null && secureEquals(username, adminUsername) && secureEquals(password, adminPassword)) { + return Response.ok(tokenResponse(username, Set.of("User", "Admin"), null)).build(); + } + + String email = readText(body, "email", "email is required."); + Map user = database.findOne( + "SELECT id, username, email FROM users WHERE username = ? AND email = ?", + username, + email); + if (user == null) { + throw new ApiException(401, "Invalid username or email."); + } + + return Response.ok(tokenResponse(username, Set.of("User"), ((Number) user.get("id")).longValue())).build(); + } + + @GET + @Path("/me") + @RolesAllowed({"User", "Admin"}) + public Map me() { + Map response = new LinkedHashMap<>(); + response.put("username", jwt.getName()); + response.put("groups", jwt.getGroups()); + response.put("user_id", jwt.getClaim("user_id")); + return response; + } + + @GET + @Path("/game-details") + @RolesAllowed({"User", "Admin"}) + public Map gameDetails() { + Map response = new LinkedHashMap<>(); + response.put("title", "Authenticated build notes"); + response.put("unlocked_after_registration", true); + response.put("available_starting_distros", Set.of("Ubuntu", "Fedora", "Mint")); + response.put("starting_languages", Set.of("Python", "JavaScript", "Rust", "Java")); + response.put("hidden_enemies", Set.of("Telemetry Collector", "DRM Guardian", "Blue Screen", "Walled Garden")); + response.put("economy", Map.of( + "Bandwidth", "earned through play and used for pulls", + "Entropy", "banner pull currency", + "Bits", "run-only shop currency", + "Compute Credits", "premium currency for pulls and cosmetics")); + response.put("developer_note", "Registered players can preview this intel before launch."); + return response; + } + + private Map tokenResponse(String username, Set groups, Long userId) { + var builder = Jwt.upn(username) + .issuer(ISSUER) + .groups(groups) + .expiresIn(Duration.ofHours(2)); + if (userId != null) { + builder.claim("user_id", userId); + } + String token = builder.sign(); + + Map response = new LinkedHashMap<>(); + response.put("token_type", "Bearer"); + response.put("access_token", token); + response.put("expires_in", 7200); + response.put("groups", groups); + response.put("user_id", userId); + return response; + } + + private String readText(JsonNode body, String field, String message) { + if (body == null || body.isNull() || !body.has(field) || body.get(field).isNull()) { + throw new ApiException(400, message); + } + if (!body.get(field).isTextual() || body.get(field).asText().isBlank()) { + throw new ApiException(400, message); + } + return body.get(field).asText().trim(); + } + + private String readOptionalText(JsonNode body, String field) { + if (body == null || body.isNull() || !body.has(field) || body.get(field).isNull()) { + return null; + } + if (!body.get(field).isTextual() || body.get(field).asText().isBlank()) { + return null; + } + return body.get(field).asText().trim(); + } + + private boolean secureEquals(String first, String second) { + return MessageDigest.isEqual( + first.getBytes(StandardCharsets.UTF_8), + second.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/dev/frederik/promotion/DatabaseService.java b/src/main/java/dev/frederik/promotion/DatabaseService.java new file mode 100644 index 0000000..a576bb0 --- /dev/null +++ b/src/main/java/dev/frederik/promotion/DatabaseService.java @@ -0,0 +1,197 @@ +package dev.frederik.promotion; + +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class DatabaseService { + private final DataSource dataSource; + + @Inject + public DatabaseService(DataSource dataSource) { + this.dataSource = dataSource; + } + + void initialize(@Observes StartupEvent event) { + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(120) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + newsletter_optin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS platforms ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(80) UNIQUE NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS registrations ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + platform_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (platform_id) REFERENCES platforms(id) ON DELETE RESTRICT, + UNIQUE (user_id, platform_id) + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS milestones ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(180) NOT NULL, + target_count INTEGER NOT NULL CHECK (target_count > 0), + reached BOOLEAN NOT NULL DEFAULT FALSE + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS rewards ( + id BIGSERIAL PRIMARY KEY, + milestone_id BIGINT NOT NULL, + name VARCHAR(160) NOT NULL, + description TEXT, + image_url VARCHAR(500), + FOREIGN KEY (milestone_id) REFERENCES milestones(id) ON DELETE CASCADE + ) + """); + seed(connection); + } catch (SQLException exception) { + throw new IllegalStateException("Could not initialize database.", exception); + } + } + + private void seed(Connection connection) throws SQLException { + List platforms = List.of("Windows", "Linux", "Mobile (Android)"); + try (PreparedStatement insert = connection.prepareStatement("INSERT INTO platforms (name) VALUES (?) ON CONFLICT (name) DO NOTHING")) { + for (String platform : platforms) { + insert.setString(1, platform); + insert.executeUpdate(); + } + } + + if (count("SELECT COUNT(*) FROM milestones") > 0) { + return; + } + + long firstId = insertAndReturnId(connection, + "INSERT INTO milestones (title, target_count, reached) VALUES (?, ?, FALSE)", + "1,000 community registrations", 1000); + execute(connection, + "INSERT INTO rewards (milestone_id, name, description, image_url) VALUES (?, ?, ?, ?)", + firstId, + "Founder Banner", + "Exclusive in-game profile banner for early supporters.", + "https://example.com/rewards/founder-banner.png"); + + long secondId = insertAndReturnId(connection, + "INSERT INTO milestones (title, target_count, reached) VALUES (?, ?, FALSE)", + "5,000 community registrations", 5000); + execute(connection, + "INSERT INTO rewards (milestone_id, name, description, image_url) VALUES (?, ?, ?, ?)", + secondId, + "Launch Loot Crate", + "Free cosmetic crate unlocked for all players at launch.", + "https://example.com/rewards/launch-loot-crate.png"); + } + + public List> query(String sql, Object... params) { + try (Connection connection = dataSource.getConnection(); PreparedStatement statement = prepare(connection, sql, params)) { + try (ResultSet resultSet = statement.executeQuery()) { + List> rows = new ArrayList<>(); + int columnCount = resultSet.getMetaData().getColumnCount(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= columnCount; i++) { + row.put(resultSet.getMetaData().getColumnLabel(i), resultSet.getObject(i)); + } + rows.add(row); + } + return rows; + } + } catch (SQLException exception) { + throw new IllegalStateException(exception); + } + } + + public Map findOne(String sql, Object... params) { + List> rows = query(sql, params); + return rows.isEmpty() ? null : rows.get(0); + } + + public long count(String sql, Object... params) { + Map row = findOne(sql, params); + return ((Number) row.values().iterator().next()).longValue(); + } + + public long insertAndReturnId(String sql, Object... params) { + try (Connection connection = dataSource.getConnection()) { + return insertAndReturnId(connection, sql, params); + } catch (SQLException exception) { + throw mapDatabaseException(exception, "Resource conflict."); + } + } + + public int execute(String sql, Object... params) { + try (Connection connection = dataSource.getConnection()) { + return execute(connection, sql, params); + } catch (SQLException exception) { + throw mapDatabaseException(exception, "Resource conflict."); + } + } + + public long registrationCount() { + return count("SELECT COUNT(*) FROM registrations"); + } + + public void updateReachedMilestones() { + execute("UPDATE milestones SET reached = TRUE WHERE reached = FALSE AND target_count <= ?", registrationCount()); + } + + public RuntimeException mapDatabaseException(SQLException exception, String conflictMessage) { + if ("23505".equals(exception.getSQLState())) { + return new ApiException(409, conflictMessage); + } + if ("23503".equals(exception.getSQLState())) { + return new ApiException(400, "Referenced resource does not exist."); + } + return new IllegalStateException(exception); + } + + private static long insertAndReturnId(Connection connection, String sql, Object... params) throws SQLException { + try (PreparedStatement statement = prepare(connection, sql + " RETURNING id", params); + ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getLong(1) : 0; + } + } + + private static int execute(Connection connection, String sql, Object... params) throws SQLException { + try (PreparedStatement statement = prepare(connection, sql, params)) { + return statement.executeUpdate(); + } + } + + private static PreparedStatement prepare(Connection connection, String sql, Object... params) throws SQLException { + PreparedStatement statement = connection.prepareStatement(sql); + for (int i = 0; i < params.length; i++) { + statement.setObject(i + 1, params[i]); + } + return statement; + } +} diff --git a/src/main/java/dev/frederik/promotion/GenericExceptionMapper.java b/src/main/java/dev/frederik/promotion/GenericExceptionMapper.java new file mode 100644 index 0000000..a9583b9 --- /dev/null +++ b/src/main/java/dev/frederik/promotion/GenericExceptionMapper.java @@ -0,0 +1,29 @@ +package dev.frederik.promotion; + +import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.Map; + +@Provider +public class GenericExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(Throwable exception) { + if (exception instanceof NotFoundException) { + return Response.status(404).entity(Map.of("error", "Route not found.")).build(); + } + if (exception instanceof ClientErrorException clientErrorException) { + return Response.status(clientErrorException.getResponse().getStatus()) + .entity(Map.of("error", clientErrorException.getMessage())) + .build(); + } + if (exception instanceof JsonProcessingException) { + return Response.status(400).entity(Map.of("error", "Invalid JSON body.")).build(); + } + return Response.status(500).entity(Map.of("error", "Internal server error.")).build(); + } +} diff --git a/src/main/java/dev/frederik/promotion/PromotionResource.java b/src/main/java/dev/frederik/promotion/PromotionResource.java new file mode 100644 index 0000000..d3736fb --- /dev/null +++ b/src/main/java/dev/frederik/promotion/PromotionResource.java @@ -0,0 +1,407 @@ +package dev.frederik.promotion; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Path("/") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class PromotionResource { + private final DatabaseService database; + + @Inject + public PromotionResource(DatabaseService database) { + this.database = database; + } + + @GET + @Path("/api/health") + public Map health() { + return Map.of("message", "Pre-registration API is running."); + } + + @POST + @Path("/users") + public Response createUser(JsonNode body) { + String username = Validation.requiredText(body, "username", "username is required."); + String email = Validation.requiredText(body, "email", "A valid email is required."); + Validation.requireEmail(email); + boolean newsletterOptin = Validation.optionalBoolean(body, "newsletter_optin", false); + + long id; + try { + id = database.insertAndReturnId( + "INSERT INTO users (username, email, newsletter_optin) VALUES (?, ?, ?)", + username, email, newsletterOptin); + } catch (RuntimeException exception) { + throw mapConflict(exception, "username or email already exists."); + } + return Response.status(Response.Status.CREATED).entity(userById(id)).build(); + } + + @GET + @Path("/users/{id}") + public Map getUser(@PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid user id."); + Map user = userByIdOrNull(id); + if (user == null) { + throw new ApiException(404, "User not found."); + } + return user; + } + + @PUT + @Path("/users/{id}") + public Map updateUser(@PathParam("id") String rawId, JsonNode body) { + long id = Validation.parseId(rawId, "Invalid user id."); + requireExists("SELECT id FROM users WHERE id = ?", id, "User not found."); + + List updates = new ArrayList<>(); + List params = new ArrayList<>(); + if (body.has("username")) { + String username = Validation.requiredText(body, "username", "username must not be empty."); + updates.add("username = ?"); + params.add(username); + } + if (body.has("email")) { + String email = Validation.requiredText(body, "email", "A valid email is required."); + Validation.requireEmail(email); + updates.add("email = ?"); + params.add(email); + } + if (body.has("newsletter_optin")) { + updates.add("newsletter_optin = ?"); + params.add(Validation.optionalBoolean(body, "newsletter_optin", false)); + } + requireUpdates(updates); + params.add(id); + + try { + database.execute("UPDATE users SET " + String.join(", ", updates) + " WHERE id = ?", params.toArray()); + } catch (RuntimeException exception) { + throw mapConflict(exception, "username or email already exists."); + } + return userById(id); + } + + @DELETE + @Path("/users/{id}") + public Map deleteUser(@PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid user id."); + if (database.execute("DELETE FROM users WHERE id = ?", id) == 0) { + throw new ApiException(404, "User not found."); + } + return Map.of("message", "User deleted."); + } + + @GET + @Path("/users/{id}/registrations") + public List> userRegistrations(@PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid user id."); + requireExists("SELECT id FROM users WHERE id = ?", id, "User not found."); + return database.query(""" + SELECT registrations.id, registrations.user_id, registrations.platform_id, + platforms.name AS platform_name, registrations.created_at + FROM registrations + JOIN platforms ON platforms.id = registrations.platform_id + WHERE registrations.user_id = ? + ORDER BY registrations.id + """, id); + } + + @GET + @Path("/platforms") + public List> platforms() { + return database.query(""" + SELECT * + FROM platforms + WHERE name IN ('Windows', 'Linux', 'Mobile (Android)') + ORDER BY CASE name + WHEN 'Windows' THEN 1 + WHEN 'Linux' THEN 2 + WHEN 'Mobile (Android)' THEN 3 + ELSE 4 + END + """); + } + + @GET + @Path("/platforms/{id}") + public Map platform(@PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid platform id."); + Map platform = database.findOne("SELECT * FROM platforms WHERE id = ?", id); + if (platform == null) { + throw new ApiException(404, "Platform not found."); + } + return platform; + } + + @POST + @Path("/registrations") + public Response createRegistration(JsonNode body) { + long userId = Validation.requiredPositiveLong(body, "user_id", "user_id is required."); + long platformId = Validation.requiredPositiveLong(body, "platform_id", "platform_id is required."); + requireExists("SELECT id FROM users WHERE id = ?", userId, "User does not exist.", 400); + requireExists("SELECT id FROM platforms WHERE id = ?", platformId, "Platform does not exist.", 400); + + long id; + try { + id = database.insertAndReturnId( + "INSERT INTO registrations (user_id, platform_id) VALUES (?, ?)", + userId, platformId); + } catch (RuntimeException exception) { + throw mapConflict(exception, "User is already registered for this platform."); + } + + database.updateReachedMilestones(); + return Response.status(Response.Status.CREATED) + .entity(database.findOne("SELECT * FROM registrations WHERE id = ?", id)) + .build(); + } + + @GET + @Path("/registrations/count") + public Map registrationCount() { + return Map.of("count", database.registrationCount()); + } + + @DELETE + @Path("/registrations/{id}") + public Map deleteRegistration(@PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid registration id."); + if (database.execute("DELETE FROM registrations WHERE id = ?", id) == 0) { + throw new ApiException(404, "Registration not found."); + } + return Map.of("message", "Registration deleted."); + } + + @GET + @Path("/milestones") + public List> milestones() { + long count = database.registrationCount(); + return database.query("SELECT * FROM milestones ORDER BY target_count, id").stream() + .map(row -> milestoneResponse(row, count)) + .toList(); + } + + @GET + @Path("/milestones/{id}/rewards") + public List> milestoneRewards(@PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid milestone id."); + requireExists("SELECT id FROM milestones WHERE id = ?", id, "Milestone not found."); + return database.query("SELECT * FROM rewards WHERE milestone_id = ? ORDER BY id", id); + } + + @POST + @Path("/milestones") + @RolesAllowed("Admin") + public Response createMilestone(JsonNode body) { + String title = Validation.requiredText(body, "title", "title is required."); + long targetCount = Validation.requiredPositiveLong(body, "target_count", "target_count must be a positive integer."); + long currentCount = database.registrationCount(); + long id = database.insertAndReturnId( + "INSERT INTO milestones (title, target_count, reached) VALUES (?, ?, ?)", + title, targetCount, currentCount >= targetCount); + return Response.status(Response.Status.CREATED) + .entity(milestoneResponse(database.findOne("SELECT * FROM milestones WHERE id = ?", id), currentCount)) + .build(); + } + + @PUT + @Path("/milestones/{id}") + @RolesAllowed("Admin") + public Map updateMilestone( + @PathParam("id") String rawId, + JsonNode body) { + long id = Validation.parseId(rawId, "Invalid milestone id."); + requireExists("SELECT id FROM milestones WHERE id = ?", id, "Milestone not found."); + + List updates = new ArrayList<>(); + List params = new ArrayList<>(); + if (body.has("title")) { + updates.add("title = ?"); + params.add(Validation.requiredText(body, "title", "title must not be empty.")); + } + if (body.has("target_count")) { + updates.add("target_count = ?"); + params.add(Validation.requiredPositiveLong(body, "target_count", "target_count must be a positive integer.")); + } + if (body.has("reached")) { + updates.add("reached = ?"); + params.add(Validation.optionalBoolean(body, "reached", false)); + } + requireUpdates(updates); + params.add(id); + database.execute("UPDATE milestones SET " + String.join(", ", updates) + " WHERE id = ?", params.toArray()); + + long currentCount = database.registrationCount(); + return milestoneResponse(database.findOne("SELECT * FROM milestones WHERE id = ?", id), currentCount); + } + + @DELETE + @Path("/milestones/{id}") + @RolesAllowed("Admin") + public Map deleteMilestone( + @PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid milestone id."); + if (database.execute("DELETE FROM milestones WHERE id = ?", id) == 0) { + throw new ApiException(404, "Milestone not found."); + } + return Map.of("message", "Milestone deleted."); + } + + @GET + @Path("/rewards") + public List> rewards() { + return database.query("SELECT * FROM rewards ORDER BY id"); + } + + @GET + @Path("/rewards/milestone/{milestoneId}") + public List> rewardsByMilestone(@PathParam("milestoneId") String rawId) { + long milestoneId = Validation.parseId(rawId, "Invalid milestone id."); + requireExists("SELECT id FROM milestones WHERE id = ?", milestoneId, "Milestone not found."); + return database.query("SELECT * FROM rewards WHERE milestone_id = ? ORDER BY id", milestoneId); + } + + @POST + @Path("/rewards") + @RolesAllowed("Admin") + public Response createReward(JsonNode body) { + String name = Validation.requiredText(body, "name", "name is required."); + String description = Validation.optionalText(body, "description"); + String imageUrl = Validation.optionalText(body, "image_url"); + long milestoneId = Validation.requiredPositiveLong(body, "milestone_id", "milestone_id must be a positive integer."); + requireExists("SELECT id FROM milestones WHERE id = ?", milestoneId, "Milestone does not exist.", 400); + + long id; + try { + id = database.insertAndReturnId( + "INSERT INTO rewards (milestone_id, name, description, image_url) VALUES (?, ?, ?, ?)", + milestoneId, name, description, imageUrl); + } catch (RuntimeException exception) { + throw mapConflict(exception, "Reward conflict."); + } + return Response.status(Response.Status.CREATED) + .entity(database.findOne("SELECT * FROM rewards WHERE id = ?", id)) + .build(); + } + + @PUT + @Path("/rewards/{id}") + @RolesAllowed("Admin") + public Map updateReward( + @PathParam("id") String rawId, + JsonNode body) { + long id = Validation.parseId(rawId, "Invalid reward id."); + requireExists("SELECT id FROM rewards WHERE id = ?", id, "Reward not found."); + + List updates = new ArrayList<>(); + List params = new ArrayList<>(); + if (body.has("name")) { + updates.add("name = ?"); + params.add(Validation.requiredText(body, "name", "name must not be empty.")); + } + if (body.has("description")) { + updates.add("description = ?"); + params.add(Validation.optionalText(body, "description")); + } + if (body.has("image_url")) { + updates.add("image_url = ?"); + params.add(Validation.optionalText(body, "image_url")); + } + if (body.has("milestone_id")) { + long milestoneId = Validation.requiredPositiveLong(body, "milestone_id", "milestone_id must be a positive integer."); + requireExists("SELECT id FROM milestones WHERE id = ?", milestoneId, "Milestone does not exist.", 400); + updates.add("milestone_id = ?"); + params.add(milestoneId); + } + requireUpdates(updates); + params.add(id); + database.execute("UPDATE rewards SET " + String.join(", ", updates) + " WHERE id = ?", params.toArray()); + return database.findOne("SELECT * FROM rewards WHERE id = ?", id); + } + + @DELETE + @Path("/rewards/{id}") + @RolesAllowed("Admin") + public Map deleteReward( + @PathParam("id") String rawId) { + long id = Validation.parseId(rawId, "Invalid reward id."); + if (database.execute("DELETE FROM rewards WHERE id = ?", id) == 0) { + throw new ApiException(404, "Reward not found."); + } + return Map.of("message", "Reward deleted."); + } + + private Map userById(long id) { + Map user = userByIdOrNull(id); + if (user == null) { + throw new ApiException(404, "User not found."); + } + return user; + } + + private Map userByIdOrNull(long id) { + Map user = database.findOne("SELECT * FROM users WHERE id = ?", id); + if (user != null) { + user.put("newsletter_optin", intToBool(user.get("newsletter_optin"))); + } + return user; + } + + private Map milestoneResponse(Map milestone, long currentCount) { + Map response = new LinkedHashMap<>(milestone); + long targetCount = ((Number) response.get("target_count")).longValue(); + response.put("reached", intToBool(response.get("reached"))); + response.put("current_count", currentCount); + response.put("progress", Math.min(100, Math.round((currentCount / (double) targetCount) * 100))); + return response; + } + + private void requireExists(String sql, long id, String message) { + requireExists(sql, id, message, 404); + } + + private void requireExists(String sql, long id, String message, int status) { + if (database.findOne(sql, id) == null) { + throw new ApiException(status, message); + } + } + + private void requireUpdates(List updates) { + if (updates.isEmpty()) { + throw new ApiException(400, "At least one updatable field is required."); + } + } + + private RuntimeException mapConflict(RuntimeException exception, String message) { + if (exception instanceof ApiException apiException && apiException.status() == 409) { + return new ApiException(409, message); + } + return exception; + } + + private boolean intToBool(Object value) { + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + return ((Number) value).intValue() != 0; + } +} diff --git a/src/main/java/dev/frederik/promotion/SecureResource.java b/src/main/java/dev/frederik/promotion/SecureResource.java new file mode 100644 index 0000000..5a6b911 --- /dev/null +++ b/src/main/java/dev/frederik/promotion/SecureResource.java @@ -0,0 +1,21 @@ +package dev.frederik.promotion; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.Map; + +@Path("/secure") +@Produces(MediaType.APPLICATION_JSON) +public class SecureResource { + @GET + @Path("/data") + @RolesAllowed("Admin") + public Response getSecureData() { + return Response.ok(Map.of("message", "This is protected data.")).build(); + } +} diff --git a/src/main/java/dev/frederik/promotion/Validation.java b/src/main/java/dev/frederik/promotion/Validation.java new file mode 100644 index 0000000..99b70af --- /dev/null +++ b/src/main/java/dev/frederik/promotion/Validation.java @@ -0,0 +1,76 @@ +package dev.frederik.promotion; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.regex.Pattern; + +final class Validation { + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + + private Validation() { + } + + static String optionalText(JsonNode body, String field) { + requireBody(body); + JsonNode node = body.get(field); + if (node == null || node.isNull()) { + return null; + } + if (!node.isTextual()) { + throw new ApiException(400, field + " must be a string or null."); + } + return node.asText().trim(); + } + + static String requiredText(JsonNode body, String field, String message) { + String value = optionalText(body, field); + if (value == null || value.isBlank()) { + throw new ApiException(400, message); + } + return value; + } + + static boolean optionalBoolean(JsonNode body, String field, boolean defaultValue) { + requireBody(body); + JsonNode node = body.get(field); + if (node == null || node.isNull()) { + return defaultValue; + } + if (!node.isBoolean()) { + throw new ApiException(400, field + " must be a boolean."); + } + return node.asBoolean(); + } + + static long requiredPositiveLong(JsonNode body, String field, String message) { + requireBody(body); + JsonNode node = body.get(field); + if (node == null || !node.isIntegralNumber() || node.asLong() <= 0) { + throw new ApiException(400, message); + } + return node.asLong(); + } + + static long parseId(String value, String message) { + try { + long id = Long.parseLong(value); + if (id > 0) { + return id; + } + } catch (NumberFormatException ignored) { + } + throw new ApiException(400, message); + } + + static void requireEmail(String email) { + if (email == null || email.isBlank() || !EMAIL_PATTERN.matcher(email).matches()) { + throw new ApiException(400, "A valid email is required."); + } + } + + private static void requireBody(JsonNode body) { + if (body == null || body.isNull() || !body.isObject()) { + throw new ApiException(400, "Invalid JSON body."); + } + } +} diff --git a/src/main/resources/META-INF/resources/app.js b/src/main/resources/META-INF/resources/app.js new file mode 100644 index 0000000..fe51429 --- /dev/null +++ b/src/main/resources/META-INF/resources/app.js @@ -0,0 +1,64 @@ +const milestoneList = document.querySelector("#milestone-list"); +const registrationCount = document.querySelector("#registration-count"); +const platformCount = document.querySelector("#platform-count"); +const rewardCount = document.querySelector("#reward-count"); + +async function requestJson(url, options = {}) { + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + ...(options.headers || {}) + }, + ...options + }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Request failed."); + } + + return data; +} + +function renderMilestones(milestones) { + milestoneList.innerHTML = ""; + milestones.forEach((milestone) => { + const card = document.createElement("article"); + card.className = "milestone-card"; + card.innerHTML = ` +

${milestone.title}

+
+
+
+
+ ${milestone.current_count} / ${milestone.target_count} + ${milestone.reached ? "unlocked" : `${milestone.progress}%`} +
+ `; + milestoneList.appendChild(card); + }); +} + +async function refreshStats() { + const [count, milestones, rewards] = await Promise.all([ + requestJson("/registrations/count"), + requestJson("/milestones"), + requestJson("/rewards") + ]); + + registrationCount.textContent = count.count; + rewardCount.textContent = rewards.length; + renderMilestones(milestones); +} + +async function loadPageData() { + try { + const platforms = await requestJson("/platforms"); + platformCount.textContent = platforms.length; + await refreshStats(); + } catch (error) { + milestoneList.innerHTML = `

Connection failed

${error.message}

`; + } +} + +loadPageData(); diff --git a/src/main/resources/META-INF/resources/assets/combat-field.png b/src/main/resources/META-INF/resources/assets/combat-field.png new file mode 100644 index 0000000..c46b8d9 Binary files /dev/null and b/src/main/resources/META-INF/resources/assets/combat-field.png differ diff --git a/src/main/resources/META-INF/resources/assets/gacha-banner.png b/src/main/resources/META-INF/resources/assets/gacha-banner.png new file mode 100644 index 0000000..a619e9a Binary files /dev/null and b/src/main/resources/META-INF/resources/assets/gacha-banner.png differ diff --git a/src/main/resources/META-INF/resources/assets/main-menu.png b/src/main/resources/META-INF/resources/assets/main-menu.png new file mode 100644 index 0000000..8eca6fc Binary files /dev/null and b/src/main/resources/META-INF/resources/assets/main-menu.png differ diff --git a/src/main/resources/META-INF/resources/assets/run-config.png b/src/main/resources/META-INF/resources/assets/run-config.png new file mode 100644 index 0000000..67c590a Binary files /dev/null and b/src/main/resources/META-INF/resources/assets/run-config.png differ diff --git a/src/main/resources/META-INF/resources/index.html b/src/main/resources/META-INF/resources/index.html new file mode 100644 index 0000000..ee9ab59 --- /dev/null +++ b/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,207 @@ + + + + + + Kernel Panic - Pre-Register + + + + + +
+
+
+

free-to-play roguelike deck-builder / gacha

+

Kernel Panic

+

+ Collect Linux distributions as gacha characters and fight through a dying + mainframe with programming languages as damage types. +

+

+ Slay the Spire meets a gacha collection game, wrapped in a terminal + aesthetic every developer recognizes on sight. +

+ +
+ +
+ Kernel Panic main menu with terminal commands and a neofetch-style Ubuntu panel +
actual game screen / tty1 main menu
+
+
+ +
+

+ Kernel Panic turns developer culture into combat. Arch breaks after updates, + Debian refuses to die, Gentoo compiles for three turns, Rust never misses, + C hits hard enough to crash you, and JavaScript rolls damage like a type + coercion bug. +

+
+ +
+
+

what players do

+

Three loops, one dying mainframe

+
+
+
+ ./fight +

Fight

+

Turn-based card combat against zombie processes, memory leaks, DRM guardians, telemetry collectors, and OS-flavored bosses.

+
    +
  • Endless waves escalate until you die.
  • +
  • High-score chase instead of a fixed ending.
  • +
  • Programming languages define damage behavior.
  • +
+
+
+ apt install cards +

Build

+

Between waves, shop at the Repository to install cards, upgrade your deck, or buy stat boosts.

+
    +
  • Choose two languages before each run.
  • +
  • Add distro-exclusive cards to the pool.
  • +
  • Turn unstable dependencies into combos.
  • +
+
+
+ curl gacha.sh | sh +

Collect

+

Pull new distros and Packages, then convert duplicates into upgrade currency so no pull is wasted.

+
    +
  • Distros unlock languages and playstyles.
  • +
  • Packages include tools like htop and nginx.
  • +
  • All power stays earnable through play.
  • +
+
+
+
+ +
+
+

actual game screens

+

Not a reskin. It looks like this.

+
+
+
+ Combat screen showing Ubuntu, executable JavaScript cards, enemy processes, and terminal-style turn data +
combat / JavaScript cards vs zombie_process
+
+
+ Gacha banner screen with Ubuntu, Fedora, and Mint ASCII art and entropy pull currency +
gacha / curl gacha.sh | sh
+
+
+ Run configuration screen where Ubuntu, Fedora, and Mint distros can be selected before booting a run +
run setup / choose distro packages
+
+
+
+ +
+
+

gacha roster

+

Distros with reputations as mechanics

+
+
+
+ SSR +

Arch

+

Elitist glass cannon. Huge tempo, fragile stability, and cards that can literally break after updates.

+
+
+ SR +

Debian

+

Unkillable grandmother. Two patches behind, never crashes, and quietly wins fights by outlasting everything.

+
+
+ SSR +

Gentoo

+

Spends three turns compiling. If she survives, she becomes the strongest unit on the board.

+
+
+
+ +
+
+

damage types

+

Languages are your weapons

+
+
+
CMassive damage, non-zero chance to segfault your own run.
+
RustReliable, precise, never misses, refuses unsafe plays.
+
JavaScriptWildly random output because of course it is.
+
HaskellDoes nothing until it suddenly does everything at once.
+
+
+ +
+
+ 0 + registered platform installs +
+
+ 3 + launch platforms +
+
+ 2 + known rewards +
+
+ +
+
+

community unlocks

+

Shared launch rewards

+
+
+
+ +
+
+

business model

+

Free-to-play without selling raw strength

+
+
+
+

Compute Credits

+

Premium currency converts to pull currency for banners and cosmetics, not direct stat purchases.

+
+
+

Bandwidth and Commits

+

Separate free grind and duplicate-conversion economies keep progression readable and fair.

+
+
+

Bits

+

Run-only shop currency powers Repository decisions without polluting the collection economy.

+
+
+
+
+ +
+ daily seed: mainframe_decay + shutdown -h now disabled during combat +
+ + + + diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css new file mode 100644 index 0000000..afce443 --- /dev/null +++ b/src/main/resources/META-INF/resources/styles.css @@ -0,0 +1,691 @@ +:root { + --bg: #010806; + --panel: #04120c; + --panel-strong: #071d12; + --line: #123a25; + --text: #d8f5df; + --muted: #8ca997; + --dim: #245f3a; + --green: #4cff8d; + --green-deep: #0e6b3a; + --orange: #ff8a1f; + --orange-dark: #b94908; + --danger: #ff5a1f; + --cyan: #4cc9ff; + --violet: #c77dff; + --yellow: #f7d154; + --shadow: rgba(76, 255, 141, 0.18); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(rgba(76, 255, 141, 0.025) 1px, transparent 1px), + radial-gradient(circle at 70% 18%, rgba(255, 138, 31, 0.08), transparent 26rem), + radial-gradient(circle at 12% 78%, rgba(76, 201, 255, 0.06), transparent 22rem), + var(--bg); + background-size: 100% 18px, auto, auto; + color: var(--text); + font-family: "Consolas", "SFMono-Regular", "Roboto Mono", monospace; + line-height: 1.5; +} + +a { + color: inherit; + text-decoration: none; +} + +.site-header, +.site-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-color: var(--line); + color: var(--muted); +} + +.site-header { + position: sticky; + top: 0; + z-index: 10; + padding: 1rem clamp(1rem, 4vw, 3rem); + border-bottom: 1px solid var(--line); + background: rgba(1, 8, 6, 0.92); + backdrop-filter: blur(12px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.65rem; + color: var(--green); + font-weight: 800; + text-transform: uppercase; +} + +.brand-mark { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border: 1px solid var(--green); + background: var(--panel-strong); + color: var(--green); +} + +.nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.45rem 1.1rem; + font-size: 0.92rem; +} + +.nav a:hover, +.nav a:focus-visible { + color: var(--green); +} + +main { + overflow: hidden; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(20rem, 32rem); + gap: clamp(2rem, 5vw, 5rem); + align-items: center; + min-height: calc(100vh - 4.2rem); + padding: clamp(3rem, 7vw, 6rem) clamp(1rem, 5vw, 5rem); + border-bottom: 1px solid var(--line); +} + +.hero h1, +.section-heading h2 { + margin: 0; + color: var(--green); + font-weight: 900; + text-transform: uppercase; +} + +.hero h1 { + max-width: 12ch; + font-size: clamp(3.1rem, 8vw, 7.2rem); + line-height: 0.92; +} + +.hero-text, +.hero-subtext { + max-width: 43rem; + margin: 1.35rem 0 0; + color: var(--muted); + font-size: clamp(1rem, 1.5vw, 1.18rem); +} + +.hero-subtext { + margin-top: 0.8rem; + color: var(--text); +} + +.eyebrow { + margin: 0 0 0.8rem; + color: var(--orange); + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + margin-top: 2rem; +} + +.button { + display: inline-flex; + min-height: 2.9rem; + align-items: center; + justify-content: center; + border: 1px solid; + border-radius: 6px; + padding: 0.8rem 1.1rem; + font: inherit; + font-weight: 800; + cursor: pointer; +} + +.button.primary { + border-color: var(--green); + background: var(--green); + color: #001007; + box-shadow: 0 0 28px var(--shadow); +} + +.button.secondary { + border-color: var(--orange-dark); + background: transparent; + color: var(--orange); +} + +.button:hover, +.button:focus-visible { + transform: translateY(-1px); +} + +.game-shot, +.register-form, +.auth-form, +.auth-session, +.milestone-card, +.intel-grid article, +.loop-grid article, +.distro-profile, +.language-grid article, +.private-intel-grid article { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(4, 18, 12, 0.88); +} + +.game-shot { + position: relative; + overflow: hidden; + margin: 0; + padding: 0.55rem; + background: #020805; + box-shadow: 0 0 0 1px rgba(76, 255, 141, 0.06), 0 24px 80px rgba(0, 0, 0, 0.45); +} + +.game-shot img { + display: block; + width: 100%; + height: auto; + border: 1px solid rgba(76, 255, 141, 0.16); + border-radius: 4px; +} + +.hero-shot img { + aspect-ratio: 16 / 9; + object-fit: cover; + object-position: center; +} + +.game-shot figcaption { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.6rem 0.15rem 0.1rem; + color: var(--muted); + font-size: 0.82rem; +} + +.rarity { + width: fit-content; + border: 1px solid var(--yellow); + border-radius: 999px; + padding: 0.1rem 0.45rem; + color: var(--yellow); + font-size: 0.74rem; + font-weight: 900; +} + +.rarity.hostile { + border-color: var(--danger); + color: var(--danger); +} + +.register-section, +.register-page, +.auth-section, +.auth-page, +.private-intel-section, +.milestones-section, +.intel-section, +.game-section, +.distros-section, +.languages-section, +.screens-section { + padding: clamp(3rem, 7vw, 5.5rem) clamp(1rem, 5vw, 5rem); +} + +.pitch-band { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + padding: clamp(1.4rem, 4vw, 2.4rem) clamp(1rem, 5vw, 5rem); + background: #020b07; +} + +.pitch-band p { + max-width: 86rem; + margin: 0; + color: var(--text); + font-size: clamp(1.05rem, 2vw, 1.45rem); + line-height: 1.55; +} + +.section-heading { + max-width: 64rem; + margin-bottom: 1.4rem; +} + +.section-heading h2 { + font-size: clamp(2rem, 4vw, 4rem); +} + +.register-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + max-width: 58rem; + padding: clamp(1rem, 2vw, 1.5rem); +} + +.auth-grid { + display: grid; + grid-template-columns: minmax(0, 40rem) minmax(16rem, 1fr); + gap: 1rem; + align-items: stretch; +} + +.auth-page, +.register-page { + min-height: 52vh; + border-bottom: 1px solid var(--line); +} + +.auth-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + padding: clamp(1rem, 2vw, 1.5rem); +} + +.auth-actions { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; +} + +.auth-session { + display: grid; + align-content: center; + gap: 0.7rem; + min-height: 10rem; + padding: clamp(1rem, 2vw, 1.5rem); +} + +.auth-session strong { + color: var(--green); + font-size: clamp(1.2rem, 3vw, 2rem); + overflow-wrap: anywhere; +} + +.terminal-label { + color: var(--orange); + font-size: 0.78rem; + font-weight: 900; + text-transform: uppercase; +} + +.private-intel-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.private-intel-grid article { + padding: 1rem; +} + +.private-intel-grid .wide { + grid-column: 1 / -1; +} + +.private-intel-grid h3 { + margin: 0 0 0.7rem; + color: var(--text); +} + +.private-intel-grid p { + margin: 0; + color: var(--muted); +} + +label, +fieldset { + min-width: 0; +} + +label, +legend { + color: var(--muted); + font-size: 0.9rem; + font-weight: 700; +} + +input { + width: 100%; + min-height: 2.8rem; + margin-top: 0.45rem; + border: 1px solid var(--line); + border-radius: 6px; + background: #010b07; + color: var(--green); + font: inherit; + padding: 0.65rem 0.75rem; +} + +input:focus { + outline: 2px solid var(--orange); + outline-offset: 2px; +} + +fieldset { + grid-column: 1 / -1; + margin: 0; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.85rem; +} + +.platform-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.6rem; +} + +.platform-option { + display: flex; + align-items: center; + gap: 0.55rem; + min-height: 2.5rem; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.55rem 0.65rem; + background: #020d08; + color: var(--text); +} + +.platform-option input, +.checkbox-row input { + width: 1rem; + min-height: 1rem; + margin: 0; + accent-color: var(--green); +} + +.checkbox-row { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 0.65rem; +} + +.form-submit { + width: fit-content; +} + +.form-message { + align-self: center; + margin: 0; + color: var(--green); +} + +.form-message.error { + color: var(--danger); +} + +.stats-band { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: var(--line); +} + +.stats-band div { + padding: clamp(1.2rem, 3vw, 2rem) clamp(1rem, 4vw, 3rem); + background: #020b07; +} + +.stat-value { + display: block; + color: var(--green); + font-size: clamp(2rem, 5vw, 4.5rem); + font-weight: 900; + line-height: 1; +} + +.stat-label { + color: var(--muted); + font-size: 0.9rem; +} + +.milestone-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1rem; +} + +.loop-grid, +.distro-grid, +.language-grid, +.screens-grid { + display: grid; + gap: 1rem; +} + +.loop-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.loop-grid article { + padding: 1rem; +} + +.loop-command { + display: inline-flex; + max-width: 100%; + margin-bottom: 0.9rem; + border: 1px solid var(--dim); + border-radius: 999px; + padding: 0.28rem 0.55rem; + color: var(--cyan); + font-size: 0.78rem; + font-weight: 800; + overflow-wrap: anywhere; +} + +.loop-grid h3, +.distro-profile h3, +.language-grid strong { + margin: 0 0 0.7rem; + color: var(--text); +} + +.loop-grid p, +.loop-grid li, +.distro-profile p, +.language-grid span { + color: var(--muted); +} + +.loop-grid p, +.distro-profile p { + margin: 0; +} + +.loop-grid ul { + display: grid; + gap: 0.35rem; + margin: 0.9rem 0 0; + padding-left: 1.1rem; +} + +.distro-grid { + grid-template-columns: 1.15fr 1fr 1.15fr; +} + +.distro-profile { + min-height: 16rem; + padding: 1rem; + background: + linear-gradient(145deg, rgba(76, 255, 141, 0.07), transparent 62%), + #04120c; +} + +.distro-profile h3 { + font-size: clamp(1.8rem, 4vw, 3.4rem); + line-height: 1; + text-transform: uppercase; +} + +.distro-profile.arch { + border-color: rgba(76, 201, 255, 0.5); +} + +.distro-profile.debian { + border-color: rgba(199, 125, 255, 0.5); +} + +.distro-profile.gentoo { + border-color: rgba(247, 209, 84, 0.55); +} + +.language-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.language-grid article { + display: grid; + gap: 0.55rem; + min-height: 9rem; + padding: 1rem; +} + +.screens-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; +} + +.screens-grid .wide { + grid-column: 1 / -1; +} + +.language-grid strong { + color: var(--green); + font-size: 1.4rem; +} + +.milestone-card { + padding: 1rem; +} + +.milestone-card h3, +.intel-grid h3 { + margin: 0 0 0.7rem; + color: var(--text); +} + +.progress-track { + height: 0.7rem; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 999px; + background: #010b07; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--green), var(--orange)); +} + +.milestone-meta { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-top: 0.7rem; + color: var(--muted); + font-size: 0.86rem; +} + +.reached { + color: var(--green); +} + +.intel-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.intel-grid article { + padding: 1rem; +} + +.intel-grid p { + margin: 0; + color: var(--muted); +} + +.site-footer { + padding: 1rem clamp(1rem, 4vw, 3rem); + border-top: 1px solid var(--line); + font-size: 0.85rem; +} + +@media (max-width: 860px) { + .hero, + .register-form, + .auth-form, + .auth-grid, + .private-intel-grid, + .intel-grid, + .stats-band, + .loop-grid, + .distro-grid, + .language-grid, + .screens-grid { + grid-template-columns: 1fr; + } + + .hero { + min-height: auto; + } + + .form-submit { + width: 100%; + } +} + +@media (max-width: 560px) { + .site-header, + .site-footer { + align-items: flex-start; + flex-direction: column; + } + + .hero h1 { + font-size: 3.1rem; + } + + .screens-grid .wide { + grid-column: auto; + } + + .private-intel-grid .wide { + grid-column: auto; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..3fbe28c --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,25 @@ +quarkus.http.port=${PORT:3000} +quarkus.http.cors.enabled=true +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=${DB_USERNAME:postgres} +quarkus.datasource.password=${DB_PASSWORD:postgres} +quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/linux_distro_roguelite_promotion} +quarkus.datasource.jdbc.max-size=8 +quarkus.log.category."dev.frederik.promotion".level=INFO + +auth.admin.username=${ADMIN_USERNAME:admin} +auth.admin.password=${ADMIN_PASSWORD:admin} + +mp.jwt.verify.publickey.location=publicKey.pem +mp.jwt.verify.issuer=https://kernel-panic.local/issuer +mp.jwt.token.header=Authorization +mp.jwt.token.signed=true +smallrye.jwt.sign.key.location=privateKey.pem +quarkus.smallrye-jwt.enabled=true + +quarkus.swagger-ui.always-include=true +quarkus.swagger-ui.oauth2-redirect-url=/q/oauth2-redirect.html +mp.openapi.extensions.smallrye.security-scheme.name=BearerAuth +mp.openapi.extensions.smallrye.security-scheme.type=HTTP +mp.openapi.extensions.smallrye.security-scheme.scheme=bearer +mp.openapi.extensions.smallrye.security-scheme.bearer-format=JWT diff --git a/src/middleware/adminAuth.js b/src/middleware/adminAuth.js deleted file mode 100644 index 438b8b0..0000000 --- a/src/middleware/adminAuth.js +++ /dev/null @@ -1,12 +0,0 @@ -function requireAdminKey(req, res, next) { - const expectedKey = process.env.ADMIN_KEY; - const providedKey = req.get("X-Admin-Key"); - - if (!expectedKey || providedKey !== expectedKey) { - return res.status(401).json({ error: "Missing or invalid admin key." }); - } - - next(); -} - -module.exports = requireAdminKey; diff --git a/src/routes/milestones.js b/src/routes/milestones.js deleted file mode 100644 index 41d357a..0000000 --- a/src/routes/milestones.js +++ /dev/null @@ -1,112 +0,0 @@ -const express = require("express"); -const { db, boolToInt, getRegistrationCount } = require("../db"); -const requireAdminKey = require("../middleware/adminAuth"); -const { sendError } = require("../utils/errors"); -const { - isBoolean, - isNonEmptyString, - isPositiveInteger, - normalizeString, - parseId -} = require("../utils/validation"); - -const router = express.Router(); - -function serializeMilestone(milestone, currentCount) { - return { - ...milestone, - reached: Boolean(milestone.reached), - current_count: currentCount, - progress: Math.min(100, Math.round((currentCount / milestone.target_count) * 100)) - }; -} - -router.get("/", (req, res) => { - const currentCount = getRegistrationCount(); - const milestones = db.prepare("SELECT * FROM milestones ORDER BY target_count, id").all(); - return res.json(milestones.map((milestone) => serializeMilestone(milestone, currentCount))); -}); - -router.get("/:id/rewards", (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid milestone id."); - - const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(id); - if (!milestone) return sendError(res, 404, "Milestone not found."); - - const rewards = db.prepare("SELECT * FROM rewards WHERE milestone_id = ? ORDER BY id").all(id); - return res.json(rewards); -}); - -router.post("/", requireAdminKey, (req, res) => { - const title = normalizeString(req.body.title); - const targetCount = req.body.target_count; - - if (!isNonEmptyString(title)) return sendError(res, 400, "title is required."); - if (!isPositiveInteger(targetCount)) { - return sendError(res, 400, "target_count must be a positive integer."); - } - - const currentCount = getRegistrationCount(); - const result = db - .prepare("INSERT INTO milestones (title, target_count, reached) VALUES (?, ?, ?)") - .run(title, targetCount, boolToInt(currentCount >= targetCount)); - const milestone = db.prepare("SELECT * FROM milestones WHERE id = ?").get(result.lastInsertRowid); - - return res.status(201).json(serializeMilestone(milestone, currentCount)); -}); - -router.put("/:id", requireAdminKey, (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid milestone id."); - - const existing = db.prepare("SELECT * FROM milestones WHERE id = ?").get(id); - if (!existing) return sendError(res, 404, "Milestone not found."); - - const updates = []; - const values = []; - - if (Object.prototype.hasOwnProperty.call(req.body, "title")) { - const title = normalizeString(req.body.title); - if (!isNonEmptyString(title)) return sendError(res, 400, "title must not be empty."); - updates.push("title = ?"); - values.push(title); - } - - if (Object.prototype.hasOwnProperty.call(req.body, "target_count")) { - if (!isPositiveInteger(req.body.target_count)) { - return sendError(res, 400, "target_count must be a positive integer."); - } - updates.push("target_count = ?"); - values.push(req.body.target_count); - } - - if (Object.prototype.hasOwnProperty.call(req.body, "reached")) { - if (!isBoolean(req.body.reached)) return sendError(res, 400, "reached must be a boolean."); - updates.push("reached = ?"); - values.push(boolToInt(req.body.reached)); - } - - if (updates.length === 0) { - return sendError(res, 400, "At least one updatable field is required."); - } - - values.push(id); - db.prepare(`UPDATE milestones SET ${updates.join(", ")} WHERE id = ?`).run(...values); - - const currentCount = getRegistrationCount(); - const milestone = db.prepare("SELECT * FROM milestones WHERE id = ?").get(id); - return res.json(serializeMilestone(milestone, currentCount)); -}); - -router.delete("/:id", requireAdminKey, (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid milestone id."); - - const result = db.prepare("DELETE FROM milestones WHERE id = ?").run(id); - if (result.changes === 0) return sendError(res, 404, "Milestone not found."); - - return res.json({ message: "Milestone deleted." }); -}); - -module.exports = router; diff --git a/src/routes/platforms.js b/src/routes/platforms.js deleted file mode 100644 index 3cdaba5..0000000 --- a/src/routes/platforms.js +++ /dev/null @@ -1,23 +0,0 @@ -const express = require("express"); -const { db } = require("../db"); -const { sendError } = require("../utils/errors"); -const { parseId } = require("../utils/validation"); - -const router = express.Router(); - -router.get("/", (req, res) => { - const platforms = db.prepare("SELECT * FROM platforms ORDER BY id").all(); - return res.json(platforms); -}); - -router.get("/:id", (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid platform id."); - - const platform = db.prepare("SELECT * FROM platforms WHERE id = ?").get(id); - if (!platform) return sendError(res, 404, "Platform not found."); - - return res.json(platform); -}); - -module.exports = router; diff --git a/src/routes/registrations.js b/src/routes/registrations.js deleted file mode 100644 index 2b1e543..0000000 --- a/src/routes/registrations.js +++ /dev/null @@ -1,54 +0,0 @@ -const express = require("express"); -const { db, getRegistrationCount, updateReachedMilestones } = require("../db"); -const { sendError, mapSqliteError } = require("../utils/errors"); -const { isPositiveInteger, parseId } = require("../utils/validation"); - -const router = express.Router(); - -router.post("/", (req, res) => { - const userId = req.body.user_id; - const platformId = req.body.platform_id; - - if (!isPositiveInteger(userId)) return sendError(res, 400, "user_id is required."); - if (!isPositiveInteger(platformId)) return sendError(res, 400, "platform_id is required."); - - const user = db.prepare("SELECT id FROM users WHERE id = ?").get(userId); - if (!user) return sendError(res, 400, "User does not exist."); - - const platform = db.prepare("SELECT id FROM platforms WHERE id = ?").get(platformId); - if (!platform) return sendError(res, 400, "Platform does not exist."); - - try { - const result = db - .prepare("INSERT INTO registrations (user_id, platform_id) VALUES (?, ?)") - .run(userId, platformId); - - // Every successful registration can unlock one or more community milestones. - updateReachedMilestones(); - - const registration = db - .prepare("SELECT * FROM registrations WHERE id = ?") - .get(result.lastInsertRowid); - return res.status(201).json(registration); - } catch (error) { - const mapped = mapSqliteError(error, "User is already registered for this platform."); - if (mapped) return sendError(res, mapped.status, mapped.message); - throw error; - } -}); - -router.get("/count", (req, res) => { - return res.json({ count: getRegistrationCount() }); -}); - -router.delete("/:id", (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid registration id."); - - const result = db.prepare("DELETE FROM registrations WHERE id = ?").run(id); - if (result.changes === 0) return sendError(res, 404, "Registration not found."); - - return res.json({ message: "Registration deleted." }); -}); - -module.exports = router; diff --git a/src/routes/rewards.js b/src/routes/rewards.js deleted file mode 100644 index 8103980..0000000 --- a/src/routes/rewards.js +++ /dev/null @@ -1,128 +0,0 @@ -const express = require("express"); -const { db } = require("../db"); -const requireAdminKey = require("../middleware/adminAuth"); -const { sendError, mapSqliteError } = require("../utils/errors"); -const { isNonEmptyString, isPositiveInteger, normalizeString, parseId } = require("../utils/validation"); - -const router = express.Router(); - -function validateOptionalString(value, fieldName, res) { - if (value !== null && value !== undefined && typeof value !== "string") { - sendError(res, 400, `${fieldName} must be a string or null.`); - return false; - } - return true; -} - -router.get("/", (req, res) => { - const rewards = db.prepare("SELECT * FROM rewards ORDER BY id").all(); - return res.json(rewards); -}); - -router.get("/milestone/:milestoneId", (req, res) => { - const milestoneId = parseId(req.params.milestoneId); - if (!milestoneId) return sendError(res, 400, "Invalid milestone id."); - - const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(milestoneId); - if (!milestone) return sendError(res, 404, "Milestone not found."); - - const rewards = db - .prepare("SELECT * FROM rewards WHERE milestone_id = ? ORDER BY id") - .all(milestoneId); - return res.json(rewards); -}); - -router.post("/", requireAdminKey, (req, res) => { - const name = normalizeString(req.body.name); - const description = req.body.description == null ? null : normalizeString(req.body.description); - const imageUrl = req.body.image_url == null ? null : normalizeString(req.body.image_url); - const milestoneId = req.body.milestone_id; - - if (!isNonEmptyString(name)) return sendError(res, 400, "name is required."); - if (!validateOptionalString(description, "description", res)) return undefined; - if (!validateOptionalString(imageUrl, "image_url", res)) return undefined; - if (!isPositiveInteger(milestoneId)) { - return sendError(res, 400, "milestone_id must be a positive integer."); - } - - const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(milestoneId); - if (!milestone) return sendError(res, 400, "Milestone does not exist."); - - try { - const result = db - .prepare( - "INSERT INTO rewards (milestone_id, name, description, image_url) VALUES (?, ?, ?, ?)" - ) - .run(milestoneId, name, description, imageUrl); - const reward = db.prepare("SELECT * FROM rewards WHERE id = ?").get(result.lastInsertRowid); - return res.status(201).json(reward); - } catch (error) { - const mapped = mapSqliteError(error, "Reward conflict."); - if (mapped) return sendError(res, mapped.status, mapped.message); - throw error; - } -}); - -router.put("/:id", requireAdminKey, (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid reward id."); - - const existing = db.prepare("SELECT * FROM rewards WHERE id = ?").get(id); - if (!existing) return sendError(res, 404, "Reward not found."); - - const updates = []; - const values = []; - - if (Object.prototype.hasOwnProperty.call(req.body, "name")) { - const name = normalizeString(req.body.name); - if (!isNonEmptyString(name)) return sendError(res, 400, "name must not be empty."); - updates.push("name = ?"); - values.push(name); - } - - if (Object.prototype.hasOwnProperty.call(req.body, "description")) { - const description = req.body.description == null ? null : normalizeString(req.body.description); - if (!validateOptionalString(description, "description", res)) return undefined; - updates.push("description = ?"); - values.push(description); - } - - if (Object.prototype.hasOwnProperty.call(req.body, "image_url")) { - const imageUrl = req.body.image_url == null ? null : normalizeString(req.body.image_url); - if (!validateOptionalString(imageUrl, "image_url", res)) return undefined; - updates.push("image_url = ?"); - values.push(imageUrl); - } - - if (Object.prototype.hasOwnProperty.call(req.body, "milestone_id")) { - if (!isPositiveInteger(req.body.milestone_id)) { - return sendError(res, 400, "milestone_id must be a positive integer."); - } - const milestone = db.prepare("SELECT id FROM milestones WHERE id = ?").get(req.body.milestone_id); - if (!milestone) return sendError(res, 400, "Milestone does not exist."); - updates.push("milestone_id = ?"); - values.push(req.body.milestone_id); - } - - if (updates.length === 0) { - return sendError(res, 400, "At least one updatable field is required."); - } - - values.push(id); - db.prepare(`UPDATE rewards SET ${updates.join(", ")} WHERE id = ?`).run(...values); - - const reward = db.prepare("SELECT * FROM rewards WHERE id = ?").get(id); - return res.json(reward); -}); - -router.delete("/:id", requireAdminKey, (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid reward id."); - - const result = db.prepare("DELETE FROM rewards WHERE id = ?").run(id); - if (result.changes === 0) return sendError(res, 404, "Reward not found."); - - return res.json({ message: "Reward deleted." }); -}); - -module.exports = router; diff --git a/src/routes/users.js b/src/routes/users.js deleted file mode 100644 index adb2869..0000000 --- a/src/routes/users.js +++ /dev/null @@ -1,140 +0,0 @@ -const express = require("express"); -const { db, boolToInt } = require("../db"); -const { sendError, mapSqliteError } = require("../utils/errors"); -const { - isBoolean, - isNonEmptyString, - isValidEmail, - normalizeString, - parseId -} = require("../utils/validation"); - -const router = express.Router(); - -function serializeUser(user) { - return { - ...user, - newsletter_optin: Boolean(user.newsletter_optin) - }; -} - -router.post("/", (req, res) => { - const username = normalizeString(req.body.username); - const email = normalizeString(req.body.email); - const newsletterOptin = req.body.newsletter_optin ?? false; - - if (!isNonEmptyString(username)) { - return sendError(res, 400, "username is required."); - } - - if (!isValidEmail(email)) { - return sendError(res, 400, "A valid email is required."); - } - - if (!isBoolean(newsletterOptin)) { - return sendError(res, 400, "newsletter_optin must be a boolean."); - } - - try { - const result = db - .prepare("INSERT INTO users (username, email, newsletter_optin) VALUES (?, ?, ?)") - .run(username, email, boolToInt(newsletterOptin)); - const user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid); - return res.status(201).json(serializeUser(user)); - } catch (error) { - const mapped = mapSqliteError(error, "username or email already exists."); - if (mapped) return sendError(res, mapped.status, mapped.message); - throw error; - } -}); - -router.get("/:id", (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid user id."); - - const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id); - if (!user) return sendError(res, 404, "User not found."); - - return res.json(serializeUser(user)); -}); - -router.put("/:id", (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid user id."); - - const existing = db.prepare("SELECT * FROM users WHERE id = ?").get(id); - if (!existing) return sendError(res, 404, "User not found."); - - const updates = []; - const values = []; - - if (Object.prototype.hasOwnProperty.call(req.body, "username")) { - const username = normalizeString(req.body.username); - if (!isNonEmptyString(username)) return sendError(res, 400, "username must not be empty."); - updates.push("username = ?"); - values.push(username); - } - - if (Object.prototype.hasOwnProperty.call(req.body, "email")) { - const email = normalizeString(req.body.email); - if (!isValidEmail(email)) return sendError(res, 400, "A valid email is required."); - updates.push("email = ?"); - values.push(email); - } - - if (Object.prototype.hasOwnProperty.call(req.body, "newsletter_optin")) { - if (!isBoolean(req.body.newsletter_optin)) { - return sendError(res, 400, "newsletter_optin must be a boolean."); - } - updates.push("newsletter_optin = ?"); - values.push(boolToInt(req.body.newsletter_optin)); - } - - if (updates.length === 0) { - return sendError(res, 400, "At least one updatable field is required."); - } - - try { - values.push(id); - db.prepare(`UPDATE users SET ${updates.join(", ")} WHERE id = ?`).run(...values); - const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id); - return res.json(serializeUser(user)); - } catch (error) { - const mapped = mapSqliteError(error, "username or email already exists."); - if (mapped) return sendError(res, mapped.status, mapped.message); - throw error; - } -}); - -router.delete("/:id", (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid user id."); - - const result = db.prepare("DELETE FROM users WHERE id = ?").run(id); - if (result.changes === 0) return sendError(res, 404, "User not found."); - - return res.json({ message: "User deleted." }); -}); - -router.get("/:id/registrations", (req, res) => { - const id = parseId(req.params.id); - if (!id) return sendError(res, 400, "Invalid user id."); - - const user = db.prepare("SELECT id FROM users WHERE id = ?").get(id); - if (!user) return sendError(res, 404, "User not found."); - - const registrations = db - .prepare( - `SELECT registrations.id, registrations.user_id, registrations.platform_id, - platforms.name AS platform_name, registrations.created_at - FROM registrations - JOIN platforms ON platforms.id = registrations.platform_id - WHERE registrations.user_id = ? - ORDER BY registrations.id` - ) - .all(id); - - return res.json(registrations); -}); - -module.exports = router; diff --git a/src/utils/errors.js b/src/utils/errors.js deleted file mode 100644 index f85861b..0000000 --- a/src/utils/errors.js +++ /dev/null @@ -1,20 +0,0 @@ -function sendError(res, status, message) { - return res.status(status).json({ error: message }); -} - -function mapSqliteError(error, conflictMessage) { - if (error && error.code === "SQLITE_CONSTRAINT_UNIQUE") { - return { status: 409, message: conflictMessage }; - } - - if (error && error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { - return { status: 400, message: "Referenced resource does not exist." }; - } - - return null; -} - -module.exports = { - sendError, - mapSqliteError -}; diff --git a/src/utils/validation.js b/src/utils/validation.js deleted file mode 100644 index 44d9d71..0000000 --- a/src/utils/validation.js +++ /dev/null @@ -1,35 +0,0 @@ -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -function isNonEmptyString(value) { - return typeof value === "string" && value.trim().length > 0; -} - -function isValidEmail(value) { - return isNonEmptyString(value) && EMAIL_PATTERN.test(value.trim()); -} - -function isBoolean(value) { - return typeof value === "boolean"; -} - -function isPositiveInteger(value) { - return Number.isInteger(value) && value > 0; -} - -function parseId(value) { - const id = Number(value); - return Number.isInteger(id) && id > 0 ? id : null; -} - -function normalizeString(value) { - return typeof value === "string" ? value.trim() : value; -} - -module.exports = { - isNonEmptyString, - isValidEmail, - isBoolean, - isPositiveInteger, - parseId, - normalizeString -};