feat(server): add POST /api/admin/version endpoint for APK deploy without restart

- Add VersionStore that persists versionCode/versionName to data/version.json
- Add POST /api/admin/version secured by BOLLWERK_ADMIN_TOKEN bearer auth
- GET /api/version now reads from VersionStore (fallback to env-vars)
- Update publish-apk.ps1 to use API call instead of SSH+sed+restart
- Update publish SKILL.md and vps-deploy SKILL.md documentation

Closes #100
This commit is contained in:
Jens Reinemann 2026-05-18 08:40:31 +02:00
parent dad15b9e94
commit 01a6d911ec
10 changed files with 325 additions and 91 deletions

View file

@ -32,10 +32,17 @@ scp → /opt/bollwerk/data/ └── GET /static/* → Dateie
2. `CheckForUpdateUseCase` vergleicht `versionInfo.versionCode > BuildConfig.VERSION_CODE` 2. `CheckForUpdateUseCase` vergleicht `versionInfo.versionCode > BuildConfig.VERSION_CODE`
3. Falls neuer → zeigt Download-Button, lädt APK herunter, installiert via `ApkInstaller` 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 <BOLLWERK_ADMIN_TOKEN>` 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 ### Wie die Homepage funktioniert
- `GET /` liefert HTML mit QR-Code (via qrcodejs) + Download-Link auf `/static/app-latest.apk` - `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). **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 ```powershell
# Neue Werte per sed in docker-compose.yml eintragen $body = @{ versionCode = $CODE; versionName = $NAME } | ConvertTo-Json -Compress
ssh root@195.246.231.210 "cd /opt/bollwerk && sed -i 's/BOLLWERK_APP_VERSION_CODE=.*/BOLLWERK_APP_VERSION_CODE=<neuer_code>/' docker-compose.yml && sed -i 's/BOLLWERK_APP_VERSION_NAME=.*/BOLLWERK_APP_VERSION_NAME=<neuer_name>/' docker-compose.yml" 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 ```bash
ssh root@195.246.231.210 "cd /opt/bollwerk && sed -i '/BOLLWERK_JWT_SECRET/a\ - BOLLWERK_APP_VERSION_CODE=<code>\n - BOLLWERK_APP_VERSION_NAME=<name>' docker-compose.yml" 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 ### Schritt 5 Verifizieren
ssh root@195.246.231.210 "cd /opt/bollwerk && docker compose up -d"
```
### Schritt 6 Verifizieren
```powershell ```powershell
# Version-API prüfen # 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 Invoke-WebRequest -Uri "https://bollwerk.online/" -UseBasicParsing | Select-Object StatusCode, StatusDescription
``` ```
Erwartete Ausgabe von `/api/version`:
```json
{"versionCode":<neuer_code>,"versionName":"<neuer_name>","apkUrl":"https://bollwerk.online/static/app-latest.apk"}
```
--- ---
## Automatisiertes Skript ## Automatisiertes Skript
Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 36: Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 35:
```powershell ```powershell
& ".github/skills/publish/publish-apk.ps1" -VersionCode 4 -VersionName "1.3" & ".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:**
| Parameter | Pflicht | Beschreibung | | Parameter | Pflicht | Beschreibung |
@ -127,16 +136,26 @@ Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 36
| Datei | Beschreibung | | Datei | Beschreibung |
| -------------------------------------------------------- | ------------------------------------------ | | -------------------------------------------------------- | ------------------------------------------ |
| `app/build.gradle.kts` (L18-19) | `versionCode` / `versionName` | | `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/resources/application.conf` | Server-Version-Defaults + Env-Var-Override |
| `server/src/main/kotlin/.../routes/VersionRoutes.kt` | Homepage + `/api/version` Endpoint | | `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"))` | | `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/.../usecase/CheckForUpdateUseCase.kt` | Update-Prüfung in der App |
| `app/src/main/java/.../ui/update/UpdateViewModel.kt` | Update-UI + Download-Logik | | `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 ## Bekannte Lücken / TODOs
- **Kein Release-Build:** Aktuell wird `assembleDebug` verwendet. Für Produktion → Signing-Config + `assembleRelease` einrichten. - **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. - **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.
- **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.

View file

@ -2,8 +2,8 @@
.SYNOPSIS .SYNOPSIS
Publiziert eine APK auf den Bollwerk VPS. Publiziert eine APK auf den Bollwerk VPS.
.DESCRIPTION .DESCRIPTION
Lädt die APK auf den VPS hoch, aktualisiert die Version in der Lädt die APK auf den VPS hoch und benachrichtigt den Server über die neue
docker-compose.yml und startet den Server-Container neu. Version via API-Endpoint (kein Container-Neustart nötig).
.PARAMETER VersionCode .PARAMETER VersionCode
Neuer versionCode (Integer, muss > aktueller sein). Neuer versionCode (Integer, muss > aktueller sein).
.PARAMETER VersionName .PARAMETER VersionName
@ -23,6 +23,14 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$VPS = "root@195.246.231.210" $VPS = "root@195.246.231.210"
$RemoteDir = "/opt/bollwerk" $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 --- # --- Preflight ---
Write-Host "=== Publish APK v$VersionName (build $VersionCode) ===" -ForegroundColor Cyan 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 Write-Host "[OK] SSH-Agent aktiv" -ForegroundColor Green
# --- Schritt 1: APK hochladen --- # --- 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" ssh $VPS "mkdir -p $RemoteDir/data"
scp $ApkPath "${VPS}:${RemoteDir}/data/app-latest.apk" scp $ApkPath "${VPS}:${RemoteDir}/data/app-latest.apk"
if ($LASTEXITCODE -ne 0) { Write-Error "APK-Upload fehlgeschlagen."; exit 1 } if ($LASTEXITCODE -ne 0) { Write-Error "APK-Upload fehlgeschlagen."; exit 1 }
Write-Host "[OK] APK hochgeladen" -ForegroundColor Green Write-Host "[OK] APK hochgeladen" -ForegroundColor Green
# --- Schritt 2: Version in docker-compose.yml aktualisieren --- # --- Schritt 2: Server über neue Version benachrichtigen ---
Write-Host "`n[2/4] Version in docker-compose.yml aktualisieren..." -ForegroundColor Yellow 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) $body = @{ versionCode = $VersionCode; versionName = $VersionName } | ConvertTo-Json -Compress
$checkCmd = 'grep -c "BOLLWERK_APP_VERSION_CODE" ' + $RemoteDir + '/docker-compose.yml 2>/dev/null || echo 0' $headers = @{
$checkResult = ssh $VPS $checkCmd "Authorization" = "Bearer $AdminToken"
# SSH kann Array zurückgeben (z.B. Banner + Ergebnis) - letzte Zeile nehmen "Content-Type" = "application/json"
if ($checkResult -is [array]) { $checkResult = $checkResult[-1] } }
$hasVersionCode = [int]($checkResult.Trim())
try {
if ($hasVersionCode -gt 0) { $response = Invoke-WebRequest -Uri "$ServerUrl/api/admin/version" `
# Update bestehende Einträge -Method POST `
$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" -Headers $headers `
ssh $VPS $sedCmd -Body $body `
} else { -UseBasicParsing
# Erstmalig hinzufügen (nach JWT_SECRET-Zeile) if ($response.StatusCode -ne 200) {
$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" Write-Error "Version-Update fehlgeschlagen: HTTP $($response.StatusCode)"
ssh $VPS $addCmd 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 Write-Host "[OK] Version gesetzt: $VersionName (build $VersionCode)" -ForegroundColor Green
# --- Schritt 3: Server neustarten --- # --- Schritt 3: Verifizieren ---
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 ---
if ($SkipVerify) { if ($SkipVerify) {
Write-Host "`n[4/4] Verifizierung uebersprungen." -ForegroundColor DarkGray Write-Host "`n[3/3] Verifizierung uebersprungen." -ForegroundColor DarkGray
} else { } else {
Write-Host "`n[4/4] Verifizieren (warte 5s auf Server-Start)..." -ForegroundColor Yellow Write-Host "`n[3/3] Verifizieren..." -ForegroundColor Yellow
Start-Sleep -Seconds 5 Start-Sleep -Seconds 2
try { $versionResponse = Invoke-WebRequest -Uri "$ServerUrl/api/version" -UseBasicParsing
$response = Invoke-WebRequest -Uri "https://bollwerk.online/api/version" -UseBasicParsing -TimeoutSec 10 $versionJson = $versionResponse.Content | ConvertFrom-Json
$versionJson = $response.Content | ConvertFrom-Json
if ($versionJson.versionCode -eq $VersionCode -and $versionJson.versionName -eq $VersionName) { 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 "[OK] /api/version: versionCode=$($versionJson.versionCode), versionName=$($versionJson.versionName)" -ForegroundColor Green
Write-Host (" APK-URL: " + $versionJson.apkUrl) -ForegroundColor DarkGray } else {
} else { Write-Warning "Version stimmt nicht ueberein! Erwartet: $VersionCode/$VersionName, Erhalten: $($versionJson.versionCode)/$($versionJson.versionName)"
Write-Warning ("Server meldet unerwartete Version: " + ($versionJson | ConvertTo-Json -Compress)) }
}
} catch { $homepageResponse = Invoke-WebRequest -Uri "$ServerUrl/" -UseBasicParsing
Write-Warning "Verifizierung fehlgeschlagen: $_" if ($homepageResponse.StatusCode -eq 200) {
Write-Host "Manuell pruefen: https://bollwerk.online/api/version" -ForegroundColor DarkGray Write-Host "[OK] Homepage erreichbar (HTTP $($homepageResponse.StatusCode))" -ForegroundColor Green
} else {
Write-Warning "Homepage liefert HTTP $($homepageResponse.StatusCode)"
} }
} }

View file

@ -137,25 +137,28 @@ Der Server nutzt JWT-basierte Authentifizierung (kein API-Key mehr).
| Variable | Pflicht | Beschreibung | | Variable | Pflicht | Beschreibung |
| ------------------------- | ------- | -------------------------------------------------- | | ------------------------- | ------- | -------------------------------------------------- |
| `BOLLWERK_JWT_SECRET` | ja | Secret für JWT-Token-Signierung (mind. 32 Zeichen) | | `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.) | | `BOLLWERK_ADMIN_PASSWORD` | nein | Admin-Passwort beim ersten Start (sonst auto-gen.) |
--- ---
## Server-Endpunkte ## Server-Endpunkte
| Endpunkt | Auth | Beschreibung | | Endpunkt | Auth | Beschreibung |
| --------------------------------- | ----- | ------------------------------------- | | --------------------------------- | ------------ | ------------------------------------- |
| `GET /api/health` | nein | Health-Check → "OK" | | `GET /api/health` | nein | Health-Check → "OK" |
| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) | | `GET /api/version` | nein | Version + APK-URL (JSON) |
| `POST /api/auth/refresh` | nein | Access-Token erneuern | | `POST /api/admin/version` | Admin-Token | Version setzen (kein Neustart nötig) |
| `GET /api/inventory` | JWT | Inventar des Users abrufen | | `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) |
| `PUT /api/inventory` | JWT | Inventar des Users hochladen | | `POST /api/auth/refresh` | nein | Access-Token erneuern |
| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten | | `GET /api/inventory` | JWT | Inventar des Users abrufen |
| `GET /api/admin/users` | Admin | Alle User auflisten | | `PUT /api/inventory` | JWT | Inventar des Users hochladen |
| `POST /api/admin/users` | Admin | Neuen User anlegen | | `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten |
| `PUT /api/admin/users/{id}` | Admin | Passwort ändern | | `GET /api/admin/users` | Admin (JWT) | Alle User auflisten |
| `DELETE /api/admin/users/{id}` | Admin | User löschen | | `POST /api/admin/users` | Admin (JWT) | Neuen User anlegen |
| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen | | `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 <accessToken>` Header mitgeschickt. JWT wird als `Authorization: Bearer <accessToken>` Header mitgeschickt.

View file

@ -7,6 +7,7 @@ import de.bollwerk.server.plugins.configureRateLimiting
import de.bollwerk.server.plugins.configureRouting import de.bollwerk.server.plugins.configureRouting
import de.bollwerk.server.plugins.configureSerialization import de.bollwerk.server.plugins.configureSerialization
import de.bollwerk.server.plugins.configureStatusPages import de.bollwerk.server.plugins.configureStatusPages
import de.bollwerk.server.store.VersionStore
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.netty.* import io.ktor.server.netty.*
import java.io.File import java.io.File
@ -26,11 +27,11 @@ internal fun Application.module() {
configurePlugins() configurePlugins()
} }
internal fun Application.configurePlugins(backupDir: File = File("/backups")) { internal fun Application.configurePlugins(backupDir: File = File("/backups"), versionStore: VersionStore? = null) {
configureSerialization() configureSerialization()
configureStatusPages() configureStatusPages()
configureCallLogging() configureCallLogging()
configureRateLimiting() configureRateLimiting()
configureAuthentication() configureAuthentication()
configureRouting(backupDir = backupDir) configureRouting(backupDir = backupDir, versionStore = versionStore)
} }

View file

@ -12,6 +12,7 @@ import de.bollwerk.server.routes.userRoutes
import de.bollwerk.server.routes.versionRoutes import de.bollwerk.server.routes.versionRoutes
import de.bollwerk.server.routes.webSocketRoutes import de.bollwerk.server.routes.webSocketRoutes
import de.bollwerk.server.security.JwtService import de.bollwerk.server.security.JwtService
import de.bollwerk.server.store.VersionStore
import de.bollwerk.server.websocket.WebSocketManager import de.bollwerk.server.websocket.WebSocketManager
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
@ -34,13 +35,22 @@ internal fun Application.configureRouting(
messageRepository: MessageRepository = MessageRepository(), messageRepository: MessageRepository = MessageRepository(),
jwtService: JwtService = JwtService(environment.config), jwtService: JwtService = JwtService(environment.config),
wsManager: WebSocketManager = WebSocketManager(), wsManager: WebSocketManager = WebSocketManager(),
backupDir: File = File("/backups") backupDir: File = File("/backups"),
versionStore: VersionStore? = null
) { ) {
val config = environment.config val config = environment.config
val appVersionCode = config.propertyOrNull("bollwerk.appVersionCode") val appVersionCode = config.propertyOrNull("bollwerk.appVersionCode")
?.getString()?.toIntOrNull() ?: 1 ?.getString()?.toIntOrNull() ?: 1
val appVersionName = config.propertyOrNull("bollwerk.appVersionName") val appVersionName = config.propertyOrNull("bollwerk.appVersionName")
?.getString() ?: "1.0.0" ?.getString() ?: "1.0.0"
val adminToken = config.propertyOrNull("bollwerk.adminToken")
?.getString() ?: ""
val effectiveVersionStore = versionStore ?: VersionStore(
dataDir = File("data"),
fallbackVersionCode = appVersionCode,
fallbackVersionName = appVersionName
)
install(WebSockets) { install(WebSockets) {
pingPeriod = 15.seconds pingPeriod = 15.seconds
@ -64,7 +74,7 @@ internal fun Application.configureRouting(
} }
// Public version endpoint & homepage // Public version endpoint & homepage
versionRoutes(appVersionCode, appVersionName) versionRoutes(effectiveVersionStore, adminToken)
// Static APK from filesystem (server/data/) // Static APK from filesystem (server/data/)
staticFiles("/static", File("data")) staticFiles("/static", File("data"))

View file

@ -1,12 +1,33 @@
package de.bollwerk.server.routes package de.bollwerk.server.routes
import de.bollwerk.server.model.VersionInfo import de.bollwerk.server.model.VersionInfo
import de.bollwerk.server.store.VersionStore
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* 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") { get("/api/version") {
val scheme = call.request.headers["X-Forwarded-Proto"] ?: "https" val scheme = call.request.headers["X-Forwarded-Proto"] ?: "https"
val host = call.request.headers["X-Forwarded-Host"] val host = call.request.headers["X-Forwarded-Host"]
@ -14,7 +35,33 @@ internal fun Route.versionRoutes(versionCode: Int, versionName: String) {
?: "localhost" ?: "localhost"
val apkUrl = "$scheme://$host/static/app-latest.apk" 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<UpdateVersionRequest>()
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("/") { get("/") {
@ -24,7 +71,7 @@ internal fun Route.versionRoutes(versionCode: Int, versionName: String) {
?: "localhost" ?: "localhost"
val apkUrl = "$scheme://$host/static/app-latest.apk" 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) call.respondText(html, ContentType.Text.Html, HttpStatusCode.OK)
} }
} }

View file

@ -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<StoredVersion>(versionFile.readText())
} catch (_: Exception) {
null
}
}
}

View file

@ -19,4 +19,6 @@ bollwerk {
appVersionCode = ${?BOLLWERK_APP_VERSION_CODE} appVersionCode = ${?BOLLWERK_APP_VERSION_CODE}
appVersionName = "1.0.0" appVersionName = "1.0.0"
appVersionName = ${?BOLLWERK_APP_VERSION_NAME} appVersionName = ${?BOLLWERK_APP_VERSION_NAME}
adminToken = ""
adminToken = ${?BOLLWERK_ADMIN_TOKEN}
} }

View file

@ -11,6 +11,7 @@ internal const val TEST_USER_ID = "test-user-id-001"
internal const val TEST_USERNAME = "testuser" internal const val TEST_USERNAME = "testuser"
internal const val TEST_ADMIN_ID = "test-admin-id-001" internal const val TEST_ADMIN_ID = "test-admin-id-001"
internal const val TEST_ADMIN_USERNAME = "testadmin" 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<String, String>) = listOf( internal fun testMapConfig(vararg extra: Pair<String, String>) = listOf(
"bollwerk.jwtSecret" to TEST_JWT_SECRET, "bollwerk.jwtSecret" to TEST_JWT_SECRET,
@ -19,7 +20,8 @@ internal fun testMapConfig(vararg extra: Pair<String, String>) = listOf(
"bollwerk.accessTokenExpiryMs" to "3600000", "bollwerk.accessTokenExpiryMs" to "3600000",
"bollwerk.refreshTokenExpiryMs" to "2592000000", "bollwerk.refreshTokenExpiryMs" to "2592000000",
"bollwerk.appVersionCode" to "42", "bollwerk.appVersionCode" to "42",
"bollwerk.appVersionName" to "2.1.0" "bollwerk.appVersionName" to "2.1.0",
"bollwerk.adminToken" to TEST_ADMIN_TOKEN
) + extra.toList() ) + extra.toList()
internal fun createTestAccessToken( internal fun createTestAccessToken(

View file

@ -2,6 +2,7 @@ package de.bollwerk.server
import de.bollwerk.server.db.DatabaseFactory import de.bollwerk.server.db.DatabaseFactory
import de.bollwerk.server.model.VersionInfo import de.bollwerk.server.model.VersionInfo
import de.bollwerk.server.store.VersionStore
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
@ -11,6 +12,7 @@ import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.nio.file.Files
class VersionEndpointTest { class VersionEndpointTest {
@ -20,8 +22,15 @@ class VersionEndpointTest {
extraConfig: List<Pair<String, String>> = emptyList(), extraConfig: List<Pair<String, String>> = emptyList(),
block: suspend ApplicationTestBuilder.() -> Unit block: suspend ApplicationTestBuilder.() -> Unit
) = testApplication { ) = 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 { environment {
config = MapApplicationConfig(*(testMapConfig() + extraConfig).toTypedArray()) this.config = MapApplicationConfig(*config.toTypedArray())
} }
application { application {
DatabaseFactory.init( DatabaseFactory.init(
@ -29,7 +38,7 @@ class VersionEndpointTest {
driver = "org.h2.Driver", driver = "org.h2.Driver",
adminPassword = "test-admin-pw" adminPassword = "test-admin-pw"
) )
configurePlugins() configurePlugins(versionStore = store)
} }
block() block()
} }
@ -158,4 +167,91 @@ class VersionEndpointTest {
// Then // Then
assertEquals(HttpStatusCode.NotFound, response.status) 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<VersionInfo>(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)
}
} }