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:
parent
dad15b9e94
commit
01a6d911ec
10 changed files with 325 additions and 91 deletions
69
.github/skills/publish/SKILL.md
vendored
69
.github/skills/publish/SKILL.md
vendored
|
|
@ -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 <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
|
||||
|
||||
- `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=<neuer_code>/' docker-compose.yml && sed -i 's/BOLLWERK_APP_VERSION_NAME=.*/BOLLWERK_APP_VERSION_NAME=<neuer_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=<code>\n - BOLLWERK_APP_VERSION_NAME=<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":<neuer_code>,"versionName":"<neuer_name>","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.
|
||||
|
|
|
|||
92
.github/skills/publish/publish-apk.ps1
vendored
92
.github/skills/publish/publish-apk.ps1
vendored
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
29
.github/skills/vps-deploy/SKILL.md
vendored
29
.github/skills/vps-deploy/SKILL.md
vendored
|
|
@ -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 <accessToken>` Header mitgeschickt.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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<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("/") {
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,4 +19,6 @@ bollwerk {
|
|||
appVersionCode = ${?BOLLWERK_APP_VERSION_CODE}
|
||||
appVersionName = "1.0.0"
|
||||
appVersionName = ${?BOLLWERK_APP_VERSION_NAME}
|
||||
adminToken = ""
|
||||
adminToken = ${?BOLLWERK_ADMIN_TOKEN}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String>) = listOf(
|
||||
"bollwerk.jwtSecret" to TEST_JWT_SECRET,
|
||||
|
|
@ -19,7 +20,8 @@ internal fun testMapConfig(vararg extra: Pair<String, String>) = 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(
|
||||
|
|
|
|||
|
|
@ -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<Pair<String, String>> = 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<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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue