Add optional API key auth to the server
build / build (push) Successful in 3m4s

Deployments reachable outside a trusted LAN need some form of access
control. Set FUELISH_API_KEY and the server requires a matching
X-API-Key header on /jobs; unset, behavior is unchanged (no auth).
The Android app gets a matching Settings field. This only checks the
key - TLS termination is left to a reverse proxy in front of the
server, not handled here.
This commit is contained in:
ml
2026-10-04 11:41:55 +02:00
parent ca85530b5a
commit 7a91a37049
13 changed files with 111 additions and 25 deletions
@@ -32,13 +32,14 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
.readTimeout(3, TimeUnit.SECONDS)
.build()
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String = withContext(Dispatchers.IO) {
try {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
.build()
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body)
.withApiKey(apiKey).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
@@ -50,8 +51,9 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
}
}
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
suspend fun getJobStatus(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get()
.withApiKey(apiKey).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
@@ -84,3 +86,9 @@ sealed interface HealthCheckResult {
data object Ok : HealthCheckResult
data class Failed(val message: String) : HealthCheckResult
}
/** Matches server.py's require_api_key(): omits the header entirely for
* a blank key rather than sending an empty one, so a server with no
* FUELISH_API_KEY configured behaves identically either way. */
private fun Request.Builder.withApiKey(apiKey: String): Request.Builder =
if (apiKey.isBlank()) this else header("X-API-Key", apiKey)
@@ -1,10 +1,14 @@
package de.ladkau.fuelish.data
/**
* Everything the app needs that isn't a saved receipt: just the fuelish
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
* Everything the app needs that isn't a saved receipt: the fuelish
* server's base URL (e.g. "http://192.168.1.10:8080") and, if the server
* has FUELISH_API_KEY set (see tooling-container/src/server.py), the
* matching key to send as X-API-Key. Blank apiKey is fine against a
* server with no key configured - that's the original no-auth setup.
* Persisted via Preferences DataStore, see [ConfigRepository].
*/
data class AppConfig(
val serverUrl: String = "",
val apiKey: String = "",
)
@@ -15,15 +15,20 @@ private val Context.configDataStore: DataStore<Preferences> by preferencesDataSt
class ConfigRepository(private val context: Context) {
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
val API_KEY = stringPreferencesKey("api_key")
}
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "")
AppConfig(
serverUrl = prefs[Keys.SERVER_URL] ?: "",
apiKey = prefs[Keys.API_KEY] ?: "",
)
}
suspend fun save(config: AppConfig) {
context.configDataStore.edit { prefs ->
prefs[Keys.SERVER_URL] = config.serverUrl
prefs[Keys.API_KEY] = config.apiKey
}
}
}
@@ -15,9 +15,11 @@ class ReceiptRepository(context: Context) {
suspend fun getById(id: Long): Receipt? = dao.getById(id)
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String =
api.submitImage(baseUrl, apiKey, imageFile)
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
suspend fun pollJob(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse =
api.getJobStatus(baseUrl, apiKey, jobId)
suspend fun save(receipt: Receipt): Long = dao.insert(receipt)
@@ -36,23 +36,24 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
_uiState.value = CaptureUiState.Uploading
try {
val baseUrl = configRepository.config.first().serverUrl
val config = configRepository.config.first()
val baseUrl = config.serverUrl
if (baseUrl.isBlank()) {
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
return@launch
}
val jobId = repository.submitImage(baseUrl, imageFile)
pollUntilDone(baseUrl, jobId)
val jobId = repository.submitImage(baseUrl, config.apiKey, imageFile)
pollUntilDone(baseUrl, config.apiKey, jobId)
} catch (e: Exception) {
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
}
}
}
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
private suspend fun pollUntilDone(baseUrl: String, apiKey: String, jobId: String) {
var lastStage: String? = null
while (true) {
val status = repository.pollJob(baseUrl, jobId)
val status = repository.pollJob(baseUrl, apiKey, jobId)
when (status.status) {
JobStatusResponse.STATUS_DONE -> {
AppLog.log("Job $jobId done")
@@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
@@ -38,6 +39,14 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = config.apiKey,
onValueChange = { viewModel.update { c -> c.copy(apiKey = it) } },
label = { Text(stringResource(R.string.settings_api_key)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { viewModel.save() }, enabled = isDirty) {
Text(stringResource(R.string.capture_save))
@@ -44,6 +44,7 @@
<string name="settings_server_url">Server URL</string>
<string name="settings_server_url_example">http://192.168.1.10:8080</string>
<string name="settings_api_key">API key (leave blank if the server has none set)</string>
<string name="settings_test_connection">Test connection</string>
<string name="settings_test_testing">Testing…</string>
<string name="settings_test_success">Connected successfully</string>