Implement many changes

This commit is contained in:
2026-07-08 10:50:33 +02:00
parent 845086d9a5
commit 2e1470aa42
31 changed files with 2091 additions and 2005 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
database.sqlite database.sqlite
.env .env
npm-debug.log* target/
.quarkus/
.m2/
+89 -27
View File
@@ -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
-1282
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -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"
}
}
+107
View File
@@ -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
View File
@@ -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}`);
});
-111
View File
@@ -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;
}
}
+25
View File
@@ -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
-12
View File
@@ -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;
-112
View File
@@ -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;
-23
View File
@@ -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;
-54
View File
@@ -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;
-128
View File
@@ -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;
-140
View File
@@ -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;
-20
View File
@@ -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
};
-35
View File
@@ -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
};