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:
parent
ec41a64b5e
commit
994d6b1b07
7 changed files with 301 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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("\"", """)}" 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\""
|
||||
}
|
||||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue