Implement many changes
This commit is contained in:
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
database.sqlite
|
database.sqlite
|
||||||
.env
|
.env
|
||||||
npm-debug.log*
|
target/
|
||||||
|
.quarkus/
|
||||||
|
.m2/
|
||||||
|
|||||||
@@ -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
|
- JDK 21
|
||||||
npm install
|
- Maven 3.9+ oder Quarkus CLI
|
||||||
```
|
- PostgreSQL 16+ oder Docker
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
|
PostgreSQL lokal mit Docker starten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ADMIN_KEY=secret npm start
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_USERNAME=admin ADMIN_PASSWORD=secret mvn quarkus:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Unter PowerShell:
|
Unter PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ADMIN_KEY = "secret"
|
$env:ADMIN_USERNAME = "admin"
|
||||||
npm start
|
$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.
|
Der Server laeuft standardmaessig auf Port `3000`. Mit `PORT` kann der Port ueberschrieben werden.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:PORT = "4000"
|
$env:PORT = "4000"
|
||||||
$env:ADMIN_KEY = "secret"
|
$env:ADMIN_USERNAME = "admin"
|
||||||
npm start
|
$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
|
- Meilensteine: 1,000 und 5,000 Community-Registrierungen
|
||||||
- Je eine Beispiel-Belohnung pro Meilenstein
|
- Je eine Beispiel-Belohnung pro Meilenstein
|
||||||
|
|
||||||
@@ -41,14 +52,65 @@ Alle Fehler haben das Format:
|
|||||||
{ "error": "beschreibende Meldung" }
|
{ "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
|
## Endpunkte
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
|
|
||||||
```bash
|
```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 <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
### Benutzer
|
||||||
|
|
||||||
`POST /users` erstellt einen neuen vorregistrierten Nutzer.
|
`POST /users` erstellt einen neuen vorregistrierten Nutzer.
|
||||||
@@ -129,29 +191,29 @@ curl -X DELETE http://localhost:3000/registrations/1
|
|||||||
curl http://localhost:3000/milestones
|
curl http://localhost:3000/milestones
|
||||||
```
|
```
|
||||||
|
|
||||||
`POST /milestones` erstellt einen Meilenstein. Admin-Key erforderlich.
|
`POST /milestones` erstellt einen Meilenstein. Admin-JWT erforderlich.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/milestones \
|
curl -X POST http://localhost:3000/milestones \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-Admin-Key: secret" \
|
-H "Authorization: Bearer <token>" \
|
||||||
-d "{\"title\":\"10,000 community registrations\",\"target_count\":10000}"
|
-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
|
```bash
|
||||||
curl -X PUT http://localhost:3000/milestones/1 \
|
curl -X PUT http://localhost:3000/milestones/1 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-Admin-Key: secret" \
|
-H "Authorization: Bearer <token>" \
|
||||||
-d "{\"title\":\"1,000 pre-registrations\",\"reached\":false}"
|
-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
|
```bash
|
||||||
curl -X DELETE http://localhost:3000/milestones/1 \
|
curl -X DELETE http://localhost:3000/milestones/1 \
|
||||||
-H "X-Admin-Key: secret"
|
-H "Authorization: Bearer <token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Belohnungen
|
### Belohnungen
|
||||||
@@ -168,29 +230,29 @@ curl http://localhost:3000/rewards
|
|||||||
curl http://localhost:3000/milestones/1/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
|
```bash
|
||||||
curl -X POST http://localhost:3000/rewards \
|
curl -X POST http://localhost:3000/rewards \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-Admin-Key: secret" \
|
-H "Authorization: Bearer <token>" \
|
||||||
-d "{\"milestone_id\":1,\"name\":\"Beta Hat\",\"description\":\"Cosmetic launch hat\",\"image_url\":\"https://example.com/rewards/beta-hat.png\"}"
|
-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
|
```bash
|
||||||
curl -X PUT http://localhost:3000/rewards/1 \
|
curl -X PUT http://localhost:3000/rewards/1 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-Admin-Key: secret" \
|
-H "Authorization: Bearer <token>" \
|
||||||
-d "{\"description\":\"Updated reward description\"}"
|
-d "{\"description\":\"Updated reward description\"}"
|
||||||
```
|
```
|
||||||
|
|
||||||
`DELETE /rewards/{id}` loescht eine Belohnung. Admin-Key erforderlich.
|
`DELETE /rewards/{id}` loescht eine Belohnung. Admin-JWT erforderlich.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X DELETE http://localhost:3000/rewards/1 \
|
curl -X DELETE http://localhost:3000/rewards/1 \
|
||||||
-H "X-Admin-Key: secret"
|
-H "Authorization: Bearer <token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Statuscodes
|
## Statuscodes
|
||||||
@@ -198,6 +260,6 @@ curl -X DELETE http://localhost:3000/rewards/1 \
|
|||||||
- `200`: Erfolgreiche Abfrage, Aktualisierung oder Loeschung
|
- `200`: Erfolgreiche Abfrage, Aktualisierung oder Loeschung
|
||||||
- `201`: Ressource erstellt
|
- `201`: Ressource erstellt
|
||||||
- `400`: Ungueltige Eingaben oder nicht existierende Fremdschluessel
|
- `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
|
- `404`: Ressource oder Route nicht gefunden
|
||||||
- `409`: Konflikt, z. B. doppelte E-Mail, doppelter Benutzername oder doppelte Nutzer-Plattform-Kombination
|
- `409`: Konflikt, z. B. doppelte E-Mail, doppelter Benutzername oder doppelte Nutzer-Plattform-Kombination
|
||||||
|
|||||||
Generated
-1282
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>dev.frederik</groupId>
|
||||||
|
<artifactId>linux-distro-roguelite-promotion</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<compiler-plugin.version>3.14.0</compiler-plugin.version>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
|
<quarkus.platform.version>3.37.1</quarkus.platform.version>
|
||||||
|
<skipITs>true</skipITs>
|
||||||
|
<surefire-plugin.version>3.5.3</surefire-plugin.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-agroal</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-jdbc-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-smallrye-jwt</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-smallrye-jwt-build</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-smallrye-openapi</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
<goal>generate-code</goal>
|
||||||
|
<goal>generate-code-tests</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>${compiler-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<parameters>true</parameters>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${surefire-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
-45
@@ -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}`);
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiException> {
|
||||||
|
@Override
|
||||||
|
public Response toResponse(ApiException exception) {
|
||||||
|
return Response.status(exception.status())
|
||||||
|
.entity(Map.of("error", exception.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> 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<String, Object> me() {
|
||||||
|
Map<String, Object> 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<String, Object> gameDetails() {
|
||||||
|
Map<String, Object> 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<String, Object> tokenResponse(String username, Set<String> 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<String, Object> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<Map<String, Object>> query(String sql, Object... params) {
|
||||||
|
try (Connection connection = dataSource.getConnection(); PreparedStatement statement = prepare(connection, sql, params)) {
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
int columnCount = resultSet.getMetaData().getColumnCount();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
Map<String, Object> 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<String, Object> findOne(String sql, Object... params) {
|
||||||
|
List<Map<String, Object>> rows = query(sql, params);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long count(String sql, Object... params) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Throwable> {
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> 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<String, Object> getUser(@PathParam("id") String rawId) {
|
||||||
|
long id = Validation.parseId(rawId, "Invalid user id.");
|
||||||
|
Map<String, Object> user = userByIdOrNull(id);
|
||||||
|
if (user == null) {
|
||||||
|
throw new ApiException(404, "User not found.");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/users/{id}")
|
||||||
|
public Map<String, Object> 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<String> updates = new ArrayList<>();
|
||||||
|
List<Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> platform(@PathParam("id") String rawId) {
|
||||||
|
long id = Validation.parseId(rawId, "Invalid platform id.");
|
||||||
|
Map<String, Object> 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<String, Object> registrationCount() {
|
||||||
|
return Map.of("count", database.registrationCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("/registrations/{id}")
|
||||||
|
public Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String> updates = new ArrayList<>();
|
||||||
|
List<Object> 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<String, Object> 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<Map<String, Object>> rewards() {
|
||||||
|
return database.query("SELECT * FROM rewards ORDER BY id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/rewards/milestone/{milestoneId}")
|
||||||
|
public List<Map<String, Object>> 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<String, Object> 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<String> updates = new ArrayList<>();
|
||||||
|
List<Object> 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<String, Object> 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<String, Object> userById(long id) {
|
||||||
|
Map<String, Object> user = userByIdOrNull(id);
|
||||||
|
if (user == null) {
|
||||||
|
throw new ApiException(404, "User not found.");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> userByIdOrNull(long id) {
|
||||||
|
Map<String, Object> 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<String, Object> milestoneResponse(Map<String, Object> milestone, long currentCount) {
|
||||||
|
Map<String, Object> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<h3>${milestone.title}</h3>
|
||||||
|
<div class="progress-track" aria-label="${milestone.progress}% progress">
|
||||||
|
<div class="progress-fill" style="width: ${milestone.progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="milestone-meta">
|
||||||
|
<span>${milestone.current_count} / ${milestone.target_count}</span>
|
||||||
|
<span class="${milestone.reached ? "reached" : ""}">${milestone.reached ? "unlocked" : `${milestone.progress}%`}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `<article class="milestone-card"><h3>Connection failed</h3><p>${error.message}</p></article>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPageData();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,207 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Kernel Panic - Pre-Register</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/" aria-label="Kernel Panic home">
|
||||||
|
<span class="brand-mark">KP</span>
|
||||||
|
<span>Kernel Panic</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav" aria-label="Primary navigation">
|
||||||
|
<a href="#game">Game</a>
|
||||||
|
<a href="#distros">Distros</a>
|
||||||
|
<a href="/auth.html">Auth</a>
|
||||||
|
<a href="/register.html">Register</a>
|
||||||
|
<a href="#milestones">Milestones</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero" aria-labelledby="hero-title">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">free-to-play roguelike deck-builder / gacha</p>
|
||||||
|
<h1 id="hero-title">Kernel Panic</h1>
|
||||||
|
<p class="hero-text">
|
||||||
|
Collect Linux distributions as gacha characters and fight through a dying
|
||||||
|
mainframe with programming languages as damage types.
|
||||||
|
</p>
|
||||||
|
<p class="hero-subtext">
|
||||||
|
Slay the Spire meets a gacha collection game, wrapped in a terminal
|
||||||
|
aesthetic every developer recognizes on sight.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button primary" href="/register.html">./pre_register</a>
|
||||||
|
<a class="button secondary" href="#game">cat game_loop.md</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure class="game-shot hero-shot">
|
||||||
|
<img src="/assets/main-menu.png" alt="Kernel Panic main menu with terminal commands and a neofetch-style Ubuntu panel">
|
||||||
|
<figcaption>actual game screen / tty1 main menu</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pitch-band" aria-label="Game hook">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="game-section" id="game" aria-labelledby="game-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">what players do</p>
|
||||||
|
<h2 id="game-title">Three loops, one dying mainframe</h2>
|
||||||
|
</div>
|
||||||
|
<div class="loop-grid">
|
||||||
|
<article>
|
||||||
|
<span class="loop-command">./fight</span>
|
||||||
|
<h3>Fight</h3>
|
||||||
|
<p>Turn-based card combat against zombie processes, memory leaks, DRM guardians, telemetry collectors, and OS-flavored bosses.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Endless waves escalate until you die.</li>
|
||||||
|
<li>High-score chase instead of a fixed ending.</li>
|
||||||
|
<li>Programming languages define damage behavior.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span class="loop-command">apt install cards</span>
|
||||||
|
<h3>Build</h3>
|
||||||
|
<p>Between waves, shop at the Repository to install cards, upgrade your deck, or buy stat boosts.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Choose two languages before each run.</li>
|
||||||
|
<li>Add distro-exclusive cards to the pool.</li>
|
||||||
|
<li>Turn unstable dependencies into combos.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span class="loop-command">curl gacha.sh | sh</span>
|
||||||
|
<h3>Collect</h3>
|
||||||
|
<p>Pull new distros and Packages, then convert duplicates into upgrade currency so no pull is wasted.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Distros unlock languages and playstyles.</li>
|
||||||
|
<li>Packages include tools like htop and nginx.</li>
|
||||||
|
<li>All power stays earnable through play.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="screens-section" aria-labelledby="screens-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">actual game screens</p>
|
||||||
|
<h2 id="screens-title">Not a reskin. It looks like this.</h2>
|
||||||
|
</div>
|
||||||
|
<div class="screens-grid">
|
||||||
|
<figure class="game-shot wide">
|
||||||
|
<img src="/assets/combat-field.png" alt="Combat screen showing Ubuntu, executable JavaScript cards, enemy processes, and terminal-style turn data">
|
||||||
|
<figcaption>combat / JavaScript cards vs zombie_process</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="game-shot">
|
||||||
|
<img src="/assets/gacha-banner.png" alt="Gacha banner screen with Ubuntu, Fedora, and Mint ASCII art and entropy pull currency">
|
||||||
|
<figcaption>gacha / curl gacha.sh | sh</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="game-shot">
|
||||||
|
<img src="/assets/run-config.png" alt="Run configuration screen where Ubuntu, Fedora, and Mint distros can be selected before booting a run">
|
||||||
|
<figcaption>run setup / choose distro packages</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="distros-section" id="distros" aria-labelledby="distros-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">gacha roster</p>
|
||||||
|
<h2 id="distros-title">Distros with reputations as mechanics</h2>
|
||||||
|
</div>
|
||||||
|
<div class="distro-grid">
|
||||||
|
<article class="distro-profile arch">
|
||||||
|
<span class="rarity">SSR</span>
|
||||||
|
<h3>Arch</h3>
|
||||||
|
<p>Elitist glass cannon. Huge tempo, fragile stability, and cards that can literally break after updates.</p>
|
||||||
|
</article>
|
||||||
|
<article class="distro-profile debian">
|
||||||
|
<span class="rarity">SR</span>
|
||||||
|
<h3>Debian</h3>
|
||||||
|
<p>Unkillable grandmother. Two patches behind, never crashes, and quietly wins fights by outlasting everything.</p>
|
||||||
|
</article>
|
||||||
|
<article class="distro-profile gentoo">
|
||||||
|
<span class="rarity">SSR</span>
|
||||||
|
<h3>Gentoo</h3>
|
||||||
|
<p>Spends three turns compiling. If she survives, she becomes the strongest unit on the board.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="languages-section" aria-labelledby="languages-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">damage types</p>
|
||||||
|
<h2 id="languages-title">Languages are your weapons</h2>
|
||||||
|
</div>
|
||||||
|
<div class="language-grid">
|
||||||
|
<article><strong>C</strong><span>Massive damage, non-zero chance to segfault your own run.</span></article>
|
||||||
|
<article><strong>Rust</strong><span>Reliable, precise, never misses, refuses unsafe plays.</span></article>
|
||||||
|
<article><strong>JavaScript</strong><span>Wildly random output because of course it is.</span></article>
|
||||||
|
<article><strong>Haskell</strong><span>Does nothing until it suddenly does everything at once.</span></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stats-band" aria-label="Community status">
|
||||||
|
<div>
|
||||||
|
<span class="stat-value" id="registration-count">0</span>
|
||||||
|
<span class="stat-label">registered platform installs</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="stat-value" id="platform-count">3</span>
|
||||||
|
<span class="stat-label">launch platforms</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="stat-value" id="reward-count">2</span>
|
||||||
|
<span class="stat-label">known rewards</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="milestones-section" id="milestones" aria-labelledby="milestones-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">community unlocks</p>
|
||||||
|
<h2 id="milestones-title">Shared launch rewards</h2>
|
||||||
|
</div>
|
||||||
|
<div class="milestone-list" id="milestone-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="intel-section" aria-labelledby="intel-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">business model</p>
|
||||||
|
<h2 id="intel-title">Free-to-play without selling raw strength</h2>
|
||||||
|
</div>
|
||||||
|
<div class="intel-grid">
|
||||||
|
<article>
|
||||||
|
<h3>Compute Credits</h3>
|
||||||
|
<p>Premium currency converts to pull currency for banners and cosmetics, not direct stat purchases.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Bandwidth and Commits</h3>
|
||||||
|
<p>Separate free grind and duplicate-conversion economies keep progression readable and fair.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Bits</h3>
|
||||||
|
<p>Run-only shop currency powers Repository decisions without polluting the collection economy.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<span>daily seed: mainframe_decay</span>
|
||||||
|
<span>shutdown -h now disabled during combat</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user