feat(server): Version-Endpoint, APK-Hosting & Homepage mit QR-Code

Routing.kt: GET /api/version (öffentlich, kein Auth) liefert JSON mit
versionCode, versionName und apkUrl (aus Request-Host abgeleitet).
GET / zeigt HTML-Homepage mit App-Name, Version und QR-Code
(clientseitiges JS via qrcode.js CDN) für direkten APK-Download.
staticFiles /static bedient APK aus server/data/ (Dateisystem).

Neue Dateien:
- VersionInfo.kt: Serializable DTO (versionCode, versionName, apkUrl)
- VersionRoutes.kt: Route-Definitionen für /api/version und /
- VersionEndpointTest.kt: 11 Tests (Endpoint, Homepage, Admin, 404)

Geänderte Dateien:
- application.conf: appVersionCode + appVersionName (mit Env-Override)
- Routing.kt: versionRoutes + staticFiles eingebunden
- TestHelpers.kt: testMapConfig um Version-Felder erweitert
- Dockerfile: data-Verzeichnis für APK-Hosting angelegt

Closes #83
This commit is contained in:
Jens Reinemann 2026-05-17 04:32:28 +02:00
parent ec41a64b5e
commit 994d6b1b07
7 changed files with 301 additions and 1 deletions

View file

@ -12,6 +12,9 @@ FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/server/build/libs/server.jar server.jar
# Create data directory for APK hosting
RUN mkdir -p /app/data
ENV KRISENVORRAT_JWT_SECRET="change-me-to-a-secure-jwt-secret-at-least-32-chars"
EXPOSE 8080

View file

@ -0,0 +1,10 @@
package de.krisenvorrat.server.model
import kotlinx.serialization.Serializable
@Serializable
internal data class VersionInfo(
val versionCode: Int,
val versionName: String,
val apkUrl: String
)

View file

@ -9,6 +9,7 @@ import de.krisenvorrat.server.routes.authRoutes
import de.krisenvorrat.server.routes.inventoryRoutes
import de.krisenvorrat.server.routes.messageRoutes
import de.krisenvorrat.server.routes.userRoutes
import de.krisenvorrat.server.routes.versionRoutes
import de.krisenvorrat.server.routes.webSocketRoutes
import de.krisenvorrat.server.security.JwtService
import de.krisenvorrat.server.websocket.WebSocketManager
@ -22,6 +23,7 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import java.io.File
import kotlin.time.Duration.Companion.seconds
private const val MAX_BODY_SIZE = 1 * 1024 * 1024L // 1 MB
@ -33,6 +35,12 @@ internal fun Application.configureRouting(
jwtService: JwtService = JwtService(environment.config),
wsManager: WebSocketManager = WebSocketManager()
) {
val config = environment.config
val appVersionCode = config.propertyOrNull("krisenvorrat.appVersionCode")
?.getString()?.toIntOrNull() ?: 1
val appVersionName = config.propertyOrNull("krisenvorrat.appVersionName")
?.getString() ?: "1.0.0"
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 30.seconds
@ -54,6 +62,12 @@ internal fun Application.configureRouting(
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
}
// Public version endpoint & homepage
versionRoutes(appVersionCode, appVersionName)
// Static APK from filesystem (server/data/)
staticFiles("/static", File("data"))
// Public auth endpoints (10 req/min per IP)
rateLimit(RATE_LIMIT_AUTH) {
authRoutes(userRepository, jwtService, inventoryRepository)

View file

@ -0,0 +1,105 @@
package de.krisenvorrat.server.routes
import de.krisenvorrat.server.model.VersionInfo
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
internal fun Route.versionRoutes(versionCode: Int, versionName: String) {
get("/api/version") {
val scheme = call.request.headers["X-Forwarded-Proto"] ?: "https"
val host = call.request.headers["X-Forwarded-Host"]
?: call.request.headers["Host"]
?: "localhost"
val apkUrl = "$scheme://$host/static/app-latest.apk"
call.respond(VersionInfo(versionCode, versionName, apkUrl))
}
get("/") {
val scheme = call.request.headers["X-Forwarded-Proto"] ?: "https"
val host = call.request.headers["X-Forwarded-Host"]
?: call.request.headers["Host"]
?: "localhost"
val apkUrl = "$scheme://$host/static/app-latest.apk"
val html = buildHomepageHtml(versionName, versionCode, apkUrl)
call.respondText(html, ContentType.Text.Html, HttpStatusCode.OK)
}
}
private fun buildHomepageHtml(versionName: String, versionCode: Int, apkUrl: String): String = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krisenvorrat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
background: white;
border-radius: 16px;
padding: 48px;
box-shadow: 0 4px 24px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
width: 90%;
}
h1 { font-size: 28px; margin-bottom: 8px; color: #1a1a1a; }
.version { color: #666; font-size: 14px; margin-bottom: 32px; }
#qrcode { margin: 0 auto 24px; }
#qrcode canvas { border-radius: 8px; }
.download-link {
display: inline-block;
background: #2196F3;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 500;
transition: background 0.2s;
}
.download-link:hover { background: #1976D2; }
.hint { margin-top: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="card">
<h1>Krisenvorrat</h1>
<p class="version">Version $versionName (Build $versionCode)</p>
<div id="qrcode"></div>
<a href="${apkUrl.replace("\"", "&quot;")}" class="download-link">APK herunterladen</a>
<p class="hint">QR-Code scannen oder Link antippen, um die App zu installieren.</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script>
new QRCode(document.getElementById("qrcode"), {
text: ${quoteJsString(apkUrl)},
width: 200,
height: 200,
correctLevel: QRCode.CorrectLevel.M
});
</script>
</body>
</html>
""".trimIndent()
private fun quoteJsString(value: String): String {
val escaped = value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
return "\"$escaped\""
}

View file

@ -15,4 +15,8 @@ krisenvorrat {
jwtAudience = "krisenvorrat-client"
accessTokenExpiryMs = 3600000
refreshTokenExpiryMs = 2592000000
appVersionCode = 1
appVersionCode = ${?KRISENVORRAT_APP_VERSION_CODE}
appVersionName = "1.0.0"
appVersionName = ${?KRISENVORRAT_APP_VERSION_NAME}
}

View file

@ -17,7 +17,9 @@ internal fun testMapConfig(vararg extra: Pair<String, String>) = listOf(
"krisenvorrat.jwtIssuer" to TEST_JWT_ISSUER,
"krisenvorrat.jwtAudience" to TEST_JWT_AUDIENCE,
"krisenvorrat.accessTokenExpiryMs" to "3600000",
"krisenvorrat.refreshTokenExpiryMs" to "2592000000"
"krisenvorrat.refreshTokenExpiryMs" to "2592000000",
"krisenvorrat.appVersionCode" to "42",
"krisenvorrat.appVersionName" to "2.1.0"
) + extra.toList()
internal fun createTestAccessToken(

View file

@ -0,0 +1,162 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.model.VersionInfo
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class VersionEndpointTest {
private val json = Json { ignoreUnknownKeys = true }
private fun testApp(
extraConfig: List<Pair<String, String>> = emptyList(),
block: suspend ApplicationTestBuilder.() -> Unit
) = testApplication {
environment {
config = MapApplicationConfig(*(testMapConfig() + extraConfig).toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
@Test
fun test_versionEndpoint_returnsVersionInfo() = testApp {
// When
val response = client.get("/api/version")
// Then
assertEquals(HttpStatusCode.OK, response.status)
val versionInfo = json.decodeFromString<VersionInfo>(response.bodyAsText())
assertEquals(42, versionInfo.versionCode)
assertEquals("2.1.0", versionInfo.versionName)
assertTrue(versionInfo.apkUrl.endsWith("/static/app-latest.apk"))
}
@Test
fun test_versionEndpoint_noAuthRequired() = testApp {
// When no bearer token
val response = client.get("/api/version")
// Then
assertEquals(HttpStatusCode.OK, response.status)
}
@Test
fun test_versionEndpoint_returnsJsonContentType() = testApp {
// When
val response = client.get("/api/version")
// Then
assertEquals(
ContentType.Application.Json.withCharset(Charsets.UTF_8),
response.contentType()
)
}
@Test
fun test_versionEndpoint_customConfig() = testApp(
extraConfig = listOf(
"krisenvorrat.appVersionCode" to "99",
"krisenvorrat.appVersionName" to "3.0.0-beta"
)
) {
// When
val response = client.get("/api/version")
// Then
val versionInfo = json.decodeFromString<VersionInfo>(response.bodyAsText())
assertEquals(99, versionInfo.versionCode)
assertEquals("3.0.0-beta", versionInfo.versionName)
}
@Test
fun test_homepage_returnsHtml() = testApp {
// When
val response = client.get("/")
// Then
assertEquals(HttpStatusCode.OK, response.status)
assertEquals(ContentType.Text.Html.withCharset(Charsets.UTF_8), response.contentType())
}
@Test
fun test_homepage_containsAppName() = testApp {
// When
val body = client.get("/").bodyAsText()
// Then
assertTrue(body.contains("Krisenvorrat"))
}
@Test
fun test_homepage_containsVersionInfo() = testApp {
// When
val body = client.get("/").bodyAsText()
// Then
assertTrue(body.contains("2.1.0"))
assertTrue(body.contains("42"))
}
@Test
fun test_homepage_containsQrCodeScript() = testApp {
// When
val body = client.get("/").bodyAsText()
// Then
assertTrue(body.contains("qrcode"))
assertTrue(body.contains("QRCode"))
}
@Test
fun test_homepage_containsApkDownloadLink() = testApp {
// When
val body = client.get("/").bodyAsText()
// Then
assertTrue(body.contains("/static/app-latest.apk"))
}
@Test
fun test_homepage_noAuthRequired() = testApp {
// When no bearer token
val response = client.get("/")
// Then
assertEquals(HttpStatusCode.OK, response.status)
}
@Test
fun test_adminRoute_stillAccessible() = testApp {
// When
val response = client.get("/admin")
// Then staticResources returns 200 for the admin folder (or redirect)
// The important thing is it doesn't return 404
assertTrue(response.status != HttpStatusCode.NotFound)
}
@Test
fun test_staticApk_returns404WhenNoFile() = testApp {
// When no APK file exists in data/
val response = client.get("/static/app-latest.apk")
// Then
assertEquals(HttpStatusCode.NotFound, response.status)
}
}