diff --git a/.github/skills/publish/SKILL.md b/.github/skills/publish/SKILL.md index f4a8ae3..701fd4d 100644 --- a/.github/skills/publish/SKILL.md +++ b/.github/skills/publish/SKILL.md @@ -32,10 +32,17 @@ scp → /opt/bollwerk/data/ └── GET /static/* → Dateie 2. `CheckForUpdateUseCase` vergleicht `versionInfo.versionCode > BuildConfig.VERSION_CODE` 3. Falls neuer → zeigt Download-Button, lädt APK herunter, installiert via `ApkInstaller` +### Wie die Version gesetzt wird + +- `POST /api/admin/version` mit `Authorization: Bearer ` Header +- Server speichert `versionCode` + `versionName` persistent in `data/version.json` +- `GET /api/version` liest aus `VersionStore` (Fallback auf Env-Vars, falls Datei fehlt) +- **Kein Container-Neustart nötig!** + ### Wie die Homepage funktioniert - `GET /` liefert HTML mit QR-Code (via qrcodejs) + Download-Link auf `/static/app-latest.apk` -- Version wird aus `BOLLWERK_APP_VERSION_CODE` / `BOLLWERK_APP_VERSION_NAME` Env-Vars gelesen +- Version wird aus `VersionStore` gelesen (persistent in `data/version.json`) --- @@ -64,28 +71,31 @@ scp app/build/outputs/apk/debug/app-debug.apk root@195.246.231.210:/opt/bollwerk **Voraussetzung:** SSH-Agent muss laufen und Key geladen sein (siehe vps-deploy Skill). -### Schritt 4 – Server-Version aktualisieren +### Schritt 4 – Server über neue Version benachrichtigen -Die Version wird über Environment-Variablen in der `docker-compose.yml` auf dem VPS gesetzt: +Die Version wird per API-Call gesetzt (kein Container-Neustart nötig): ```powershell -# Neue Werte per sed in docker-compose.yml eintragen -ssh root@195.246.231.210 "cd /opt/bollwerk && sed -i 's/BOLLWERK_APP_VERSION_CODE=.*/BOLLWERK_APP_VERSION_CODE=/' docker-compose.yml && sed -i 's/BOLLWERK_APP_VERSION_NAME=.*/BOLLWERK_APP_VERSION_NAME=/' docker-compose.yml" +$body = @{ versionCode = $CODE; versionName = $NAME } | ConvertTo-Json -Compress +Invoke-WebRequest -Uri "https://bollwerk.online/api/admin/version" ` + -Method POST ` + -Headers @{ "Authorization" = "Bearer $env:BOLLWERK_ADMIN_TOKEN"; "Content-Type" = "application/json" } ` + -Body $body ` + -UseBasicParsing ``` -Falls die Env-Vars noch nicht in der docker-compose.yml stehen, müssen sie einmalig hinzugefügt werden: +Alternativ mit curl: -```powershell -ssh root@195.246.231.210 "cd /opt/bollwerk && sed -i '/BOLLWERK_JWT_SECRET/a\ - BOLLWERK_APP_VERSION_CODE=\n - BOLLWERK_APP_VERSION_NAME=' docker-compose.yml" +```bash +curl -s -X POST https://bollwerk.online/api/admin/version \ + -H "Authorization: Bearer $BOLLWERK_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"versionCode\": $CODE, \"versionName\": \"$NAME\"}" ``` -### Schritt 5 – Server neustarten +**Voraussetzung:** Die Umgebungsvariable `BOLLWERK_ADMIN_TOKEN` muss gesetzt sein. Das Token muss mit dem `BOLLWERK_ADMIN_TOKEN` auf dem VPS übereinstimmen. -```powershell -ssh root@195.246.231.210 "cd /opt/bollwerk && docker compose up -d" -``` - -### Schritt 6 – Verifizieren +### Schritt 5 – Verifizieren ```powershell # Version-API prüfen @@ -95,22 +105,21 @@ Invoke-WebRequest -Uri "https://bollwerk.online/api/version" -UseBasicParsing | Invoke-WebRequest -Uri "https://bollwerk.online/" -UseBasicParsing | Select-Object StatusCode, StatusDescription ``` -Erwartete Ausgabe von `/api/version`: - -```json -{"versionCode":,"versionName":"","apkUrl":"https://bollwerk.online/static/app-latest.apk"} -``` - --- ## Automatisiertes Skript -Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 3–6: +Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 3–5: ```powershell & ".github/skills/publish/publish-apk.ps1" -VersionCode 4 -VersionName "1.3" ``` +**Voraussetzungen:** + +- SSH-Agent muss laufen (für APK-Upload per SCP) +- `$env:BOLLWERK_ADMIN_TOKEN` muss gesetzt sein (für Version-Notify API-Call) + **Parameter:** | Parameter | Pflicht | Beschreibung | @@ -127,16 +136,26 @@ Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 3–6 | Datei | Beschreibung | | -------------------------------------------------------- | ------------------------------------------ | | `app/build.gradle.kts` (L18-19) | `versionCode` / `versionName` | -| `server/src/main/resources/application.conf` (L18-21) | Server-Version-Defaults + Env-Var-Override | -| `server/src/main/kotlin/.../routes/VersionRoutes.kt` | Homepage + `/api/version` Endpoint | +| `server/src/main/resources/application.conf` | Server-Version-Defaults + Env-Var-Override | +| `server/src/main/kotlin/.../store/VersionStore.kt` | Persistente Version in `data/version.json` | +| `server/src/main/kotlin/.../routes/VersionRoutes.kt` | Homepage + `/api/version` + POST-Endpoint | | `server/src/main/kotlin/.../plugins/Routing.kt` | `staticFiles("/static", File("data"))` | | `app/src/main/java/.../usecase/CheckForUpdateUseCase.kt` | Update-Prüfung in der App | | `app/src/main/java/.../ui/update/UpdateViewModel.kt` | Update-UI + Download-Logik | --- +## Environment-Variablen (VPS) + +| Variable | Pflicht | Beschreibung | +| ------------------------------- | ------- | --------------------------------------------------------- | +| `BOLLWERK_ADMIN_TOKEN` | ja | Bearer-Token für `POST /api/admin/version` (min. 32 Zeichen) | +| `BOLLWERK_APP_VERSION_CODE` | nein | Fallback-VersionCode (nur wenn `data/version.json` fehlt) | +| `BOLLWERK_APP_VERSION_NAME` | nein | Fallback-VersionName (nur wenn `data/version.json` fehlt) | + +--- + ## Bekannte Lücken / TODOs - **Kein Release-Build:** Aktuell wird `assembleDebug` verwendet. Für Produktion → Signing-Config + `assembleRelease` einrichten. -- **Kein HTTPS:** Server läuft auf HTTP. QR-Code zeigt `http://`-URL. Für Play Store / Sicherheit → Caddy als Reverse Proxy. -- **Manuelle Version-Env-Vars:** Beim ersten Publish müssen die Env-Vars in die VPS docker-compose eingefügt werden. Das Skript erledigt das automatisch. +- **Env-Vars optional:** `BOLLWERK_APP_VERSION_CODE` / `BOLLWERK_APP_VERSION_NAME` können aus der docker-compose.yml entfernt werden, sobald `data/version.json` initial gesetzt ist. diff --git a/.github/skills/publish/publish-apk.ps1 b/.github/skills/publish/publish-apk.ps1 index 2f9b3e8..a58ed06 100644 --- a/.github/skills/publish/publish-apk.ps1 +++ b/.github/skills/publish/publish-apk.ps1 @@ -2,8 +2,8 @@ .SYNOPSIS Publiziert eine APK auf den Bollwerk VPS. .DESCRIPTION - Lädt die APK auf den VPS hoch, aktualisiert die Version in der - docker-compose.yml und startet den Server-Container neu. + Lädt die APK auf den VPS hoch und benachrichtigt den Server über die neue + Version via API-Endpoint (kein Container-Neustart nötig). .PARAMETER VersionCode Neuer versionCode (Integer, muss > aktueller sein). .PARAMETER VersionName @@ -23,6 +23,14 @@ param( $ErrorActionPreference = "Stop" $VPS = "root@195.246.231.210" $RemoteDir = "/opt/bollwerk" +$ServerUrl = "https://bollwerk.online" + +# --- Admin-Token laden --- +$AdminToken = $env:BOLLWERK_ADMIN_TOKEN +if (-not $AdminToken) { + Write-Error "Umgebungsvariable BOLLWERK_ADMIN_TOKEN ist nicht gesetzt. Bitte setzen: `$env:BOLLWERK_ADMIN_TOKEN = 'dein-token'" + exit 1 +} # --- Preflight --- Write-Host "=== Publish APK v$VersionName (build $VersionCode) ===" -ForegroundColor Cyan @@ -41,60 +49,58 @@ if ($LASTEXITCODE -ne 0) { Write-Host "[OK] SSH-Agent aktiv" -ForegroundColor Green # --- Schritt 1: APK hochladen --- -Write-Host "`n[1/4] APK hochladen..." -ForegroundColor Yellow +Write-Host "`n[1/3] APK hochladen..." -ForegroundColor Yellow ssh $VPS "mkdir -p $RemoteDir/data" scp $ApkPath "${VPS}:${RemoteDir}/data/app-latest.apk" if ($LASTEXITCODE -ne 0) { Write-Error "APK-Upload fehlgeschlagen."; exit 1 } Write-Host "[OK] APK hochgeladen" -ForegroundColor Green -# --- Schritt 2: Version in docker-compose.yml aktualisieren --- -Write-Host "`n[2/4] Version in docker-compose.yml aktualisieren..." -ForegroundColor Yellow +# --- Schritt 2: Server über neue Version benachrichtigen --- +Write-Host "`n[2/3] Server-Version aktualisieren (API-Call)..." -ForegroundColor Yellow -# Prüfen ob Env-Vars bereits vorhanden sind (bash-Syntax in einfachen Anführungszeichen) -$checkCmd = 'grep -c "BOLLWERK_APP_VERSION_CODE" ' + $RemoteDir + '/docker-compose.yml 2>/dev/null || echo 0' -$checkResult = ssh $VPS $checkCmd -# SSH kann Array zurückgeben (z.B. Banner + Ergebnis) - letzte Zeile nehmen -if ($checkResult -is [array]) { $checkResult = $checkResult[-1] } -$hasVersionCode = [int]($checkResult.Trim()) - -if ($hasVersionCode -gt 0) { - # Update bestehende Einträge - $sedCmd = "cd $RemoteDir; sed -i 's/BOLLWERK_APP_VERSION_CODE=.*/BOLLWERK_APP_VERSION_CODE=$VersionCode/' docker-compose.yml; sed -i 's/BOLLWERK_APP_VERSION_NAME=.*/BOLLWERK_APP_VERSION_NAME=$VersionName/' docker-compose.yml" - ssh $VPS $sedCmd -} else { - # Erstmalig hinzufügen (nach JWT_SECRET-Zeile) - $addCmd = "cd $RemoteDir; sed -i '/BOLLWERK_JWT_SECRET/a\ - BOLLWERK_APP_VERSION_CODE=$VersionCode' docker-compose.yml; sed -i '/BOLLWERK_APP_VERSION_CODE/a\ - BOLLWERK_APP_VERSION_NAME=$VersionName' docker-compose.yml" - ssh $VPS $addCmd +$body = @{ versionCode = $VersionCode; versionName = $VersionName } | ConvertTo-Json -Compress +$headers = @{ + "Authorization" = "Bearer $AdminToken" + "Content-Type" = "application/json" +} + +try { + $response = Invoke-WebRequest -Uri "$ServerUrl/api/admin/version" ` + -Method POST ` + -Headers $headers ` + -Body $body ` + -UseBasicParsing + if ($response.StatusCode -ne 200) { + Write-Error "Version-Update fehlgeschlagen: HTTP $($response.StatusCode)" + exit 1 + } +} catch { + Write-Error "Version-Update fehlgeschlagen: $_" + exit 1 } -if ($LASTEXITCODE -ne 0) { Write-Error "Version-Update fehlgeschlagen."; exit 1 } Write-Host "[OK] Version gesetzt: $VersionName (build $VersionCode)" -ForegroundColor Green -# --- Schritt 3: Server neustarten --- -Write-Host "`n[3/4] Server-Container neustarten..." -ForegroundColor Yellow -ssh $VPS "cd $RemoteDir; docker compose up -d" -if ($LASTEXITCODE -ne 0) { Write-Error "Container-Neustart fehlgeschlagen."; exit 1 } -Write-Host "[OK] Container neu gestartet" -ForegroundColor Green - -# --- Schritt 4: Verifizieren --- +# --- Schritt 3: Verifizieren --- if ($SkipVerify) { - Write-Host "`n[4/4] Verifizierung uebersprungen." -ForegroundColor DarkGray + Write-Host "`n[3/3] Verifizierung uebersprungen." -ForegroundColor DarkGray } else { - Write-Host "`n[4/4] Verifizieren (warte 5s auf Server-Start)..." -ForegroundColor Yellow - Start-Sleep -Seconds 5 + Write-Host "`n[3/3] Verifizieren..." -ForegroundColor Yellow + Start-Sleep -Seconds 2 - try { - $response = Invoke-WebRequest -Uri "https://bollwerk.online/api/version" -UseBasicParsing -TimeoutSec 10 - $versionJson = $response.Content | ConvertFrom-Json + $versionResponse = Invoke-WebRequest -Uri "$ServerUrl/api/version" -UseBasicParsing + $versionJson = $versionResponse.Content | ConvertFrom-Json - if ($versionJson.versionCode -eq $VersionCode -and $versionJson.versionName -eq $VersionName) { - Write-Host "[OK] Server meldet Version $($versionJson.versionName) (build $($versionJson.versionCode))" -ForegroundColor Green - Write-Host (" APK-URL: " + $versionJson.apkUrl) -ForegroundColor DarkGray - } else { - Write-Warning ("Server meldet unerwartete Version: " + ($versionJson | ConvertTo-Json -Compress)) - } - } catch { - Write-Warning "Verifizierung fehlgeschlagen: $_" - Write-Host "Manuell pruefen: https://bollwerk.online/api/version" -ForegroundColor DarkGray + if ($versionJson.versionCode -eq $VersionCode -and $versionJson.versionName -eq $VersionName) { + Write-Host "[OK] /api/version: versionCode=$($versionJson.versionCode), versionName=$($versionJson.versionName)" -ForegroundColor Green + } else { + Write-Warning "Version stimmt nicht ueberein! Erwartet: $VersionCode/$VersionName, Erhalten: $($versionJson.versionCode)/$($versionJson.versionName)" + } + + $homepageResponse = Invoke-WebRequest -Uri "$ServerUrl/" -UseBasicParsing + if ($homepageResponse.StatusCode -eq 200) { + Write-Host "[OK] Homepage erreichbar (HTTP $($homepageResponse.StatusCode))" -ForegroundColor Green + } else { + Write-Warning "Homepage liefert HTTP $($homepageResponse.StatusCode)" } } diff --git a/.github/skills/vps-deploy/SKILL.md b/.github/skills/vps-deploy/SKILL.md index 3009c78..ce392c7 100644 --- a/.github/skills/vps-deploy/SKILL.md +++ b/.github/skills/vps-deploy/SKILL.md @@ -137,25 +137,28 @@ Der Server nutzt JWT-basierte Authentifizierung (kein API-Key mehr). | Variable | Pflicht | Beschreibung | | ------------------------- | ------- | -------------------------------------------------- | | `BOLLWERK_JWT_SECRET` | ja | Secret für JWT-Token-Signierung (mind. 32 Zeichen) | +| `BOLLWERK_ADMIN_TOKEN` | ja | Bearer-Token für POST /api/admin/version (mind. 32 Zeichen) | | `BOLLWERK_ADMIN_PASSWORD` | nein | Admin-Passwort beim ersten Start (sonst auto-gen.) | --- ## Server-Endpunkte -| Endpunkt | Auth | Beschreibung | -| --------------------------------- | ----- | ------------------------------------- | -| `GET /api/health` | nein | Health-Check → "OK" | -| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) | -| `POST /api/auth/refresh` | nein | Access-Token erneuern | -| `GET /api/inventory` | JWT | Inventar des Users abrufen | -| `PUT /api/inventory` | JWT | Inventar des Users hochladen | -| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten | -| `GET /api/admin/users` | Admin | Alle User auflisten | -| `POST /api/admin/users` | Admin | Neuen User anlegen | -| `PUT /api/admin/users/{id}` | Admin | Passwort ändern | -| `DELETE /api/admin/users/{id}` | Admin | User löschen | -| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen | +| Endpunkt | Auth | Beschreibung | +| --------------------------------- | ------------ | ------------------------------------- | +| `GET /api/health` | nein | Health-Check → "OK" | +| `GET /api/version` | nein | Version + APK-URL (JSON) | +| `POST /api/admin/version` | Admin-Token | Version setzen (kein Neustart nötig) | +| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) | +| `POST /api/auth/refresh` | nein | Access-Token erneuern | +| `GET /api/inventory` | JWT | Inventar des Users abrufen | +| `PUT /api/inventory` | JWT | Inventar des Users hochladen | +| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten | +| `GET /api/admin/users` | Admin (JWT) | Alle User auflisten | +| `POST /api/admin/users` | Admin (JWT) | Neuen User anlegen | +| `PUT /api/admin/users/{id}` | Admin (JWT) | Passwort ändern | +| `DELETE /api/admin/users/{id}` | Admin (JWT) | User löschen | +| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen | JWT wird als `Authorization: Bearer ` Header mitgeschickt. diff --git a/server/src/main/kotlin/de/bollwerk/server/Application.kt b/server/src/main/kotlin/de/bollwerk/server/Application.kt index 19db6e0..11482af 100644 --- a/server/src/main/kotlin/de/bollwerk/server/Application.kt +++ b/server/src/main/kotlin/de/bollwerk/server/Application.kt @@ -7,6 +7,7 @@ import de.bollwerk.server.plugins.configureRateLimiting import de.bollwerk.server.plugins.configureRouting import de.bollwerk.server.plugins.configureSerialization import de.bollwerk.server.plugins.configureStatusPages +import de.bollwerk.server.store.VersionStore import io.ktor.server.application.* import io.ktor.server.netty.* import java.io.File @@ -26,11 +27,11 @@ internal fun Application.module() { configurePlugins() } -internal fun Application.configurePlugins(backupDir: File = File("/backups")) { +internal fun Application.configurePlugins(backupDir: File = File("/backups"), versionStore: VersionStore? = null) { configureSerialization() configureStatusPages() configureCallLogging() configureRateLimiting() configureAuthentication() - configureRouting(backupDir = backupDir) + configureRouting(backupDir = backupDir, versionStore = versionStore) } diff --git a/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt b/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt index 83da3c1..9ca7146 100644 --- a/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt @@ -12,6 +12,7 @@ import de.bollwerk.server.routes.userRoutes import de.bollwerk.server.routes.versionRoutes import de.bollwerk.server.routes.webSocketRoutes import de.bollwerk.server.security.JwtService +import de.bollwerk.server.store.VersionStore import de.bollwerk.server.websocket.WebSocketManager import io.ktor.http.* import io.ktor.server.application.* @@ -34,13 +35,22 @@ internal fun Application.configureRouting( messageRepository: MessageRepository = MessageRepository(), jwtService: JwtService = JwtService(environment.config), wsManager: WebSocketManager = WebSocketManager(), - backupDir: File = File("/backups") + backupDir: File = File("/backups"), + versionStore: VersionStore? = null ) { val config = environment.config val appVersionCode = config.propertyOrNull("bollwerk.appVersionCode") ?.getString()?.toIntOrNull() ?: 1 val appVersionName = config.propertyOrNull("bollwerk.appVersionName") ?.getString() ?: "1.0.0" + val adminToken = config.propertyOrNull("bollwerk.adminToken") + ?.getString() ?: "" + + val effectiveVersionStore = versionStore ?: VersionStore( + dataDir = File("data"), + fallbackVersionCode = appVersionCode, + fallbackVersionName = appVersionName + ) install(WebSockets) { pingPeriod = 15.seconds @@ -64,7 +74,7 @@ internal fun Application.configureRouting( } // Public version endpoint & homepage - versionRoutes(appVersionCode, appVersionName) + versionRoutes(effectiveVersionStore, adminToken) // Static APK from filesystem (server/data/) staticFiles("/static", File("data")) diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/VersionRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/VersionRoutes.kt index 85ea280..306fb9b 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/VersionRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/VersionRoutes.kt @@ -1,12 +1,33 @@ package de.bollwerk.server.routes import de.bollwerk.server.model.VersionInfo +import de.bollwerk.server.store.VersionStore import io.ktor.http.* import io.ktor.server.application.* +import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import kotlinx.serialization.Serializable -internal fun Route.versionRoutes(versionCode: Int, versionName: String) { +@Serializable +internal data class UpdateVersionRequest( + val versionCode: Int, + val versionName: String +) + +@Serializable +internal data class UpdateVersionResponse( + val status: String, + val versionCode: Int, + val versionName: String +) + +@Serializable +internal data class VersionError( + val error: String +) + +internal fun Route.versionRoutes(versionStore: VersionStore, adminToken: String) { get("/api/version") { val scheme = call.request.headers["X-Forwarded-Proto"] ?: "https" val host = call.request.headers["X-Forwarded-Host"] @@ -14,7 +35,33 @@ internal fun Route.versionRoutes(versionCode: Int, versionName: String) { ?: "localhost" val apkUrl = "$scheme://$host/static/app-latest.apk" - call.respond(VersionInfo(versionCode, versionName, apkUrl)) + call.respond(VersionInfo(versionStore.versionCode, versionStore.versionName, apkUrl)) + } + + post("/api/admin/version") { + if (adminToken.isBlank()) { + call.respond(HttpStatusCode.ServiceUnavailable, VersionError("Admin token not configured")) + return@post + } + val authHeader = call.request.header(HttpHeaders.Authorization) + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + call.respond(HttpStatusCode.Unauthorized, VersionError("Missing or invalid Authorization header")) + return@post + } + val token = authHeader.removePrefix("Bearer ").trim() + if (token != adminToken) { + call.respond(HttpStatusCode.Forbidden, VersionError("Invalid admin token")) + return@post + } + + val request = call.receive() + if (request.versionCode < 1 || request.versionName.isBlank()) { + call.respond(HttpStatusCode.BadRequest, VersionError("versionCode must be >= 1 and versionName must not be blank")) + return@post + } + + versionStore.update(request.versionCode, request.versionName) + call.respond(HttpStatusCode.OK, UpdateVersionResponse("ok", request.versionCode, request.versionName)) } get("/") { @@ -24,7 +71,7 @@ internal fun Route.versionRoutes(versionCode: Int, versionName: String) { ?: "localhost" val apkUrl = "$scheme://$host/static/app-latest.apk" - val html = buildHomepageHtml(versionName, versionCode, apkUrl) + val html = buildHomepageHtml(versionStore.versionName, versionStore.versionCode, apkUrl) call.respondText(html, ContentType.Text.Html, HttpStatusCode.OK) } } diff --git a/server/src/main/kotlin/de/bollwerk/server/store/VersionStore.kt b/server/src/main/kotlin/de/bollwerk/server/store/VersionStore.kt new file mode 100644 index 0000000..de54905 --- /dev/null +++ b/server/src/main/kotlin/de/bollwerk/server/store/VersionStore.kt @@ -0,0 +1,48 @@ +package de.bollwerk.server.store + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + +@Serializable +internal data class StoredVersion( + val versionCode: Int, + val versionName: String +) + +internal class VersionStore( + private val dataDir: File, + private val fallbackVersionCode: Int, + private val fallbackVersionName: String +) { + private val versionFile = File(dataDir, "version.json") + private val json = Json { prettyPrint = true } + + @Volatile + private var cached: StoredVersion? = null + + init { + cached = readFromFile() + } + + val versionCode: Int get() = cached?.versionCode ?: fallbackVersionCode + val versionName: String get() = cached?.versionName ?: fallbackVersionName + + @Synchronized + fun update(versionCode: Int, versionName: String) { + val version = StoredVersion(versionCode, versionName) + dataDir.mkdirs() + versionFile.writeText(json.encodeToString(version)) + cached = version + } + + private fun readFromFile(): StoredVersion? { + if (!versionFile.exists()) return null + return try { + json.decodeFromString(versionFile.readText()) + } catch (_: Exception) { + null + } + } +} diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf index 0315799..065c900 100644 --- a/server/src/main/resources/application.conf +++ b/server/src/main/resources/application.conf @@ -19,4 +19,6 @@ bollwerk { appVersionCode = ${?BOLLWERK_APP_VERSION_CODE} appVersionName = "1.0.0" appVersionName = ${?BOLLWERK_APP_VERSION_NAME} + adminToken = "" + adminToken = ${?BOLLWERK_ADMIN_TOKEN} } diff --git a/server/src/test/kotlin/de/bollwerk/server/TestHelpers.kt b/server/src/test/kotlin/de/bollwerk/server/TestHelpers.kt index a86fb35..8c963fb 100644 --- a/server/src/test/kotlin/de/bollwerk/server/TestHelpers.kt +++ b/server/src/test/kotlin/de/bollwerk/server/TestHelpers.kt @@ -11,6 +11,7 @@ internal const val TEST_USER_ID = "test-user-id-001" internal const val TEST_USERNAME = "testuser" internal const val TEST_ADMIN_ID = "test-admin-id-001" internal const val TEST_ADMIN_USERNAME = "testadmin" +internal const val TEST_ADMIN_TOKEN = "test-admin-token-for-version-updates" internal fun testMapConfig(vararg extra: Pair) = listOf( "bollwerk.jwtSecret" to TEST_JWT_SECRET, @@ -19,7 +20,8 @@ internal fun testMapConfig(vararg extra: Pair) = listOf( "bollwerk.accessTokenExpiryMs" to "3600000", "bollwerk.refreshTokenExpiryMs" to "2592000000", "bollwerk.appVersionCode" to "42", - "bollwerk.appVersionName" to "2.1.0" + "bollwerk.appVersionName" to "2.1.0", + "bollwerk.adminToken" to TEST_ADMIN_TOKEN ) + extra.toList() internal fun createTestAccessToken( diff --git a/server/src/test/kotlin/de/bollwerk/server/VersionEndpointTest.kt b/server/src/test/kotlin/de/bollwerk/server/VersionEndpointTest.kt index 63c79ac..298e7f8 100644 --- a/server/src/test/kotlin/de/bollwerk/server/VersionEndpointTest.kt +++ b/server/src/test/kotlin/de/bollwerk/server/VersionEndpointTest.kt @@ -2,6 +2,7 @@ package de.bollwerk.server import de.bollwerk.server.db.DatabaseFactory import de.bollwerk.server.model.VersionInfo +import de.bollwerk.server.store.VersionStore import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* @@ -11,6 +12,7 @@ import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.nio.file.Files class VersionEndpointTest { @@ -20,8 +22,15 @@ class VersionEndpointTest { extraConfig: List> = emptyList(), block: suspend ApplicationTestBuilder.() -> Unit ) = testApplication { + val tempDir = Files.createTempDirectory("bollwerk-test-version").toFile() + tempDir.deleteOnExit() + val config = testMapConfig() + extraConfig + val versionCode = config.lastOrNull { it.first == "bollwerk.appVersionCode" }?.second?.toIntOrNull() ?: 42 + val versionName = config.lastOrNull { it.first == "bollwerk.appVersionName" }?.second ?: "2.1.0" + val store = VersionStore(tempDir, versionCode, versionName) + environment { - config = MapApplicationConfig(*(testMapConfig() + extraConfig).toTypedArray()) + this.config = MapApplicationConfig(*config.toTypedArray()) } application { DatabaseFactory.init( @@ -29,7 +38,7 @@ class VersionEndpointTest { driver = "org.h2.Driver", adminPassword = "test-admin-pw" ) - configurePlugins() + configurePlugins(versionStore = store) } block() } @@ -158,4 +167,91 @@ class VersionEndpointTest { // Then assertEquals(HttpStatusCode.NotFound, response.status) } + + // --- POST /api/admin/version tests --- + + @Test + fun test_postVersion_updatesVersion() = testApp { + // When + val response = client.post("/api/admin/version") { + header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"versionCode": 50, "versionName": "3.0.0"}""") + } + + // Then + assertEquals(HttpStatusCode.OK, response.status) + + // Verify GET reflects the new version + val getResponse = client.get("/api/version") + val versionInfo = json.decodeFromString(getResponse.bodyAsText()) + assertEquals(50, versionInfo.versionCode) + assertEquals("3.0.0", versionInfo.versionName) + } + + @Test + fun test_postVersion_noAuthHeader_returns401() = testApp { + // When + val response = client.post("/api/admin/version") { + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"versionCode": 50, "versionName": "3.0.0"}""") + } + + // Then + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun test_postVersion_wrongToken_returns403() = testApp { + // When + val response = client.post("/api/admin/version") { + header(HttpHeaders.Authorization, "Bearer wrong-token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"versionCode": 50, "versionName": "3.0.0"}""") + } + + // Then + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun test_postVersion_invalidVersionCode_returns400() = testApp { + // When + val response = client.post("/api/admin/version") { + header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"versionCode": 0, "versionName": "1.0"}""") + } + + // Then + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun test_postVersion_blankVersionName_returns400() = testApp { + // When + val response = client.post("/api/admin/version") { + header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"versionCode": 5, "versionName": ""}""") + } + + // Then + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun test_postVersion_noAdminTokenConfigured_returns503() = testApp( + extraConfig = listOf("bollwerk.adminToken" to "") + ) { + // When + val response = client.post("/api/admin/version") { + header(HttpHeaders.Authorization, "Bearer some-token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"versionCode": 5, "versionName": "1.0"}""") + } + + // Then + assertEquals(HttpStatusCode.ServiceUnavailable, response.status) + } }