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`
|
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 3–6:
|
Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 3–5:
|
||||||
|
|
||||||
```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 3–6
|
||||||
| 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.
|
|
||||||
|
|
|
||||||
92
.github/skills/publish/publish-apk.ps1
vendored
92
.github/skills/publish/publish-apk.ps1
vendored
|
|
@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 |
|
| 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
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}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue