From 7a91a3704993ecec0ddf25fa6e5e40daffef33f0 Mon Sep 17 00:00:00 2001 From: ml Date: Sun, 4 Oct 2026 11:41:55 +0200 Subject: [PATCH] Add optional API key auth to the server 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. --- .env.example | 7 ++++ .gitea/workflows/build.yml | 9 +++++- .gitignore | 3 ++ README.md | 19 ++++++++--- .../java/de/ladkau/fuelish/data/ApiClient.kt | 16 +++++++--- .../java/de/ladkau/fuelish/data/AppConfig.kt | 8 +++-- .../ladkau/fuelish/data/ConfigRepository.kt | 7 +++- .../ladkau/fuelish/data/ReceiptRepository.kt | 6 ++-- .../fuelish/ui/capture/CaptureViewModel.kt | 11 ++++--- .../fuelish/ui/settings/ConfigScreen.kt | 9 ++++++ android/app/src/main/res/values/strings.xml | 1 + docker-compose.yml | 8 +++++ tooling-container/src/server.py | 32 ++++++++++++++++--- 13 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..da06bbe --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env (gitignored, never commit the real value) to require an +# API key on the server's /jobs endpoint - see docker-compose.yml and +# tooling-container/src/server.py. Leave FUELISH_API_KEY unset/empty for +# the original LAN-only/no-auth setup. +# +# Generate one with: openssl rand -hex 32 +FUELISH_API_KEY= diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 82473d4..57b7af3 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -66,8 +66,15 @@ jobs: set -euo pipefail APK="$(ls dist/fuelish-*.apk)" mkdir -p ~/.ssh - echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key + printf '%s\n' '${{ secrets.DL_SFTP_KEY }}' > ~/.ssh/dl_sftp_key chmod 600 ~/.ssh/dl_sftp_key + # Sanity-check the key is actually loadable before attempting the + # SFTP connection - fails fast with a clear message instead of + # SFTP's much less obvious "Permission denied" when the key + # material itself is malformed (e.g. mangled by how the secret + # was entered into Gitea - see DL_SFTP_KEY in README.md). + ssh-keygen -y -f ~/.ssh/dl_sftp_key > /dev/null \ + || { echo "FAIL: ~/.ssh/dl_sftp_key is not a valid private key - re-check the DL_SFTP_KEY secret" >&2; exit 1; } sftp -i ~/.ssh/dl_sftp_key -P 2223 \ -o StrictHostKeyChecking=accept-new \ uploader@dl.ladkau.de <:8080` (both HTTP-only - see the app's -`network_security_config.xml` - and LAN-only by design, no auth/TLS). -Take a photo, watch the 4-stage progress (upload → preprocess → OCR → -extract), correct anything the model got wrong, and save. The Receipts -tab lists everything saved so far and exports to CSV or `.ods`. +`http://:8080`. Take a photo, watch the progress (upload → +preprocess+OCR → extract), correct anything the model got wrong, and +save. The Receipts tab lists everything saved so far and exports to CSV +or `.ods`. ## License diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt index e149d96..6e37425 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ApiClient.kt @@ -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) diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/AppConfig.kt b/android/app/src/main/java/de/ladkau/fuelish/data/AppConfig.kt index b002845..87f7c0d 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/AppConfig.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/AppConfig.kt @@ -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 = "", ) diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ConfigRepository.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ConfigRepository.kt index 35e10f4..6d6c04b 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/ConfigRepository.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ConfigRepository.kt @@ -15,15 +15,20 @@ private val Context.configDataStore: DataStore 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 = 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 } } } diff --git a/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt index 6f15059..2bf8647 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/data/ReceiptRepository.kt @@ -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) diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt index 6522adc..8fe991a 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/capture/CaptureViewModel.kt @@ -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") diff --git a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt index bca6f6b..d762e7e 100644 --- a/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt +++ b/android/app/src/main/java/de/ladkau/fuelish/ui/settings/ConfigScreen.kt @@ -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)) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e90fb56..783b120 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Server URL http://192.168.1.10:8080 + API key (leave blank if the server has none set) Test connection Testing… Connected successfully diff --git a/docker-compose.yml b/docker-compose.yml index 851f146..4c62c2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,13 @@ # # Usage: docker compose up -d # API reachable at http://:8080 from any device on the LAN. +# +# For a deployment reachable outside a trusted LAN, copy .env.example to +# .env and set FUELISH_API_KEY - every /jobs request then needs a +# matching X-API-Key header (see tooling-container/src/server.py). +# Unset (the default), the server has no auth, same as before. This +# server does not terminate TLS itself - put a reverse proxy in front of +# it for anything public. services: ollama: image: ollama/ollama @@ -26,6 +33,7 @@ services: OLLAMA_URL: http://ollama:11434/api/chat OLLAMA_BASE_URL: http://ollama:11434 OLLAMA_MODEL: qwen2.5:3b + FUELISH_API_KEY: ${FUELISH_API_KEY:-} command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"] ports: - "8080:8000" diff --git a/tooling-container/src/server.py b/tooling-container/src/server.py index 2bcd1c6..73e3c47 100644 --- a/tooling-container/src/server.py +++ b/tooling-container/src/server.py @@ -10,8 +10,13 @@ Jobs are processed asynchronously so the app can show real per-stage progress: POST /jobs returns immediately with a job id; GET /jobs/{id} reports which of preprocessing/ocr/extracting is currently running, and the final result once done. Job state lives in memory only - this is a -personal, LAN-only, single-instance server with no need for persistence -here (the Android app is the system of record for confirmed receipts). +personal, single-instance server with no need for persistence here (the +Android app is the system of record for confirmed receipts). + +Auth: set FUELISH_API_KEY to require a matching X-API-Key header on +/jobs (GET /health stays open for reverse-proxy health checks). Leave it +unset for the original LAN-only/no-auth setup. This server does not +terminate TLS itself - see require_api_key()'s docstring. """ import os import shutil @@ -21,17 +26,34 @@ import time import uuid import requests -from fastapi import FastAPI, HTTPException, UploadFile +from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile from fastapi.responses import JSONResponse +from fastapi.security.api_key import APIKeyHeader from adaptive import preprocess_and_ocr_adaptive from extract import structure OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434") OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b") +API_KEY = os.environ.get("FUELISH_API_KEY", "") app = FastAPI(title="fuelish") +_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +def require_api_key(provided: str | None = Security(_api_key_header)) -> None: + """No-op when FUELISH_API_KEY isn't set - the unchanged default for a + LAN-only deployment with no key configured. When it is set, every + request to a protected route must carry a matching X-API-Key header. + This only checks the key; it doesn't terminate TLS - a reverse proxy + in front of this server (e.g. Traefik) is expected to do that for any + deployment reachable outside a trusted LAN.""" + if not API_KEY: + return + if provided != API_KEY: + raise HTTPException(status_code=401, detail="invalid or missing API key") + _jobs: dict[str, dict] = {} _jobs_lock = threading.Lock() @@ -96,7 +118,7 @@ def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None: shutil.rmtree(scratch_dir, ignore_errors=True) -@app.post("/jobs", status_code=201) +@app.post("/jobs", status_code=201, dependencies=[Depends(require_api_key)]) async def create_job(image: UploadFile) -> JSONResponse: job_id = uuid.uuid4().hex scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-") @@ -113,7 +135,7 @@ async def create_job(image: UploadFile) -> JSONResponse: return JSONResponse({"job_id": job_id}, status_code=201) -@app.get("/jobs/{job_id}") +@app.get("/jobs/{job_id}", dependencies=[Depends(require_api_key)]) def get_job(job_id: str) -> dict: with _jobs_lock: job = _jobs.get(job_id)