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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user