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:
@@ -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=
|
||||||
@@ -66,8 +66,15 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
APK="$(ls dist/fuelish-*.apk)"
|
APK="$(ls dist/fuelish-*.apk)"
|
||||||
mkdir -p ~/.ssh
|
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
|
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 \
|
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
|
||||||
-o StrictHostKeyChecking=accept-new \
|
-o StrictHostKeyChecking=accept-new \
|
||||||
uploader@dl.ladkau.de <<EOF
|
uploader@dl.ladkau.de <<EOF
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# Local registry credentials (see registry.env.example)
|
# Local registry credentials (see registry.env.example)
|
||||||
/registry.env
|
/registry.env
|
||||||
|
|
||||||
|
# Local API key for docker-compose.yml's api service (see .env.example)
|
||||||
|
/.env
|
||||||
|
|
||||||
# Local Claude Code settings
|
# Local Claude Code settings
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
|
|
||||||
|
|||||||
@@ -157,12 +157,21 @@ is found next to the photo):
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
By default this has no auth (fine for a LAN-only setup - see the app's
|
||||||
|
`network_security_config.xml` for the HTTP-only/private-IP-range
|
||||||
|
restriction this implies). For a deployment reachable outside a trusted
|
||||||
|
LAN, copy `.env.example` to `.env`, set `FUELISH_API_KEY` (e.g.
|
||||||
|
`openssl rand -hex 32`), and enter the same key in the Android app's
|
||||||
|
Settings tab alongside the server URL - every request then needs a
|
||||||
|
matching `X-API-Key` header (see `tooling-container/src/server.py`).
|
||||||
|
This only checks the key; it doesn't add TLS - put a reverse proxy in
|
||||||
|
front of the server for anything public.
|
||||||
|
|
||||||
Then, in the Android app's Settings tab, point it at
|
Then, in the Android app's Settings tab, point it at
|
||||||
`http://<this-host>:8080` (both HTTP-only - see the app's
|
`http://<this-host>:8080`. Take a photo, watch the progress (upload →
|
||||||
`network_security_config.xml` - and LAN-only by design, no auth/TLS).
|
preprocess+OCR → extract), correct anything the model got wrong, and
|
||||||
Take a photo, watch the 4-stage progress (upload → preprocess → OCR →
|
save. The Receipts tab lists everything saved so far and exports to CSV
|
||||||
extract), correct anything the model got wrong, and save. The Receipts
|
or `.ods`.
|
||||||
tab lists everything saved so far and exports to CSV or `.ods`.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
|||||||
.readTimeout(3, TimeUnit.SECONDS)
|
.readTimeout(3, TimeUnit.SECONDS)
|
||||||
.build()
|
.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 {
|
try {
|
||||||
val body = MultipartBody.Builder()
|
val body = MultipartBody.Builder()
|
||||||
.setType(MultipartBody.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
|
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
|
||||||
.build()
|
.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 ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
||||||
val text = response.body?.string().orEmpty()
|
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) {
|
suspend fun getJobStatus(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
|
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get()
|
||||||
|
.withApiKey(apiKey).build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
|
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
|
||||||
val text = response.body?.string().orEmpty()
|
val text = response.body?.string().orEmpty()
|
||||||
@@ -84,3 +86,9 @@ sealed interface HealthCheckResult {
|
|||||||
data object Ok : HealthCheckResult
|
data object Ok : HealthCheckResult
|
||||||
data class Failed(val message: String) : 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
|
package de.ladkau.fuelish.data
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the app needs that isn't a saved receipt: just the fuelish
|
* Everything the app needs that isn't a saved receipt: the fuelish
|
||||||
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
|
* 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].
|
* Persisted via Preferences DataStore, see [ConfigRepository].
|
||||||
*/
|
*/
|
||||||
data class AppConfig(
|
data class AppConfig(
|
||||||
val serverUrl: String = "",
|
val serverUrl: String = "",
|
||||||
|
val apiKey: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ private val Context.configDataStore: DataStore<Preferences> by preferencesDataSt
|
|||||||
class ConfigRepository(private val context: Context) {
|
class ConfigRepository(private val context: Context) {
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val SERVER_URL = stringPreferencesKey("server_url")
|
val SERVER_URL = stringPreferencesKey("server_url")
|
||||||
|
val API_KEY = stringPreferencesKey("api_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
|
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) {
|
suspend fun save(config: AppConfig) {
|
||||||
context.configDataStore.edit { prefs ->
|
context.configDataStore.edit { prefs ->
|
||||||
prefs[Keys.SERVER_URL] = config.serverUrl
|
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 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)
|
suspend fun save(receipt: Receipt): Long = dao.insert(receipt)
|
||||||
|
|
||||||
|
|||||||
@@ -36,23 +36,24 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = CaptureUiState.Uploading
|
_uiState.value = CaptureUiState.Uploading
|
||||||
try {
|
try {
|
||||||
val baseUrl = configRepository.config.first().serverUrl
|
val config = configRepository.config.first()
|
||||||
|
val baseUrl = config.serverUrl
|
||||||
if (baseUrl.isBlank()) {
|
if (baseUrl.isBlank()) {
|
||||||
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
|
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val jobId = repository.submitImage(baseUrl, imageFile)
|
val jobId = repository.submitImage(baseUrl, config.apiKey, imageFile)
|
||||||
pollUntilDone(baseUrl, jobId)
|
pollUntilDone(baseUrl, config.apiKey, jobId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
|
_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
|
var lastStage: String? = null
|
||||||
while (true) {
|
while (true) {
|
||||||
val status = repository.pollJob(baseUrl, jobId)
|
val status = repository.pollJob(baseUrl, apiKey, jobId)
|
||||||
when (status.status) {
|
when (status.status) {
|
||||||
JobStatusResponse.STATUS_DONE -> {
|
JobStatusResponse.STATUS_DONE -> {
|
||||||
AppLog.log("Job $jobId done")
|
AppLog.log("Job $jobId done")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.ladkau.fuelish.R
|
import de.ladkau.fuelish.R
|
||||||
@@ -38,6 +39,14 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = { viewModel.save() }, enabled = isDirty) {
|
Button(onClick = { viewModel.save() }, enabled = isDirty) {
|
||||||
Text(stringResource(R.string.capture_save))
|
Text(stringResource(R.string.capture_save))
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
|
|
||||||
<string name="settings_server_url">Server URL</string>
|
<string name="settings_server_url">Server URL</string>
|
||||||
<string name="settings_server_url_example">http://192.168.1.10:8080</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_connection">Test connection</string>
|
||||||
<string name="settings_test_testing">Testing…</string>
|
<string name="settings_test_testing">Testing…</string>
|
||||||
<string name="settings_test_success">Connected successfully</string>
|
<string name="settings_test_success">Connected successfully</string>
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
#
|
#
|
||||||
# Usage: docker compose up -d
|
# Usage: docker compose up -d
|
||||||
# API reachable at http://<this-host>:8080 from any device on the LAN.
|
# API reachable at http://<this-host>: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:
|
services:
|
||||||
ollama:
|
ollama:
|
||||||
image: ollama/ollama
|
image: ollama/ollama
|
||||||
@@ -26,6 +33,7 @@ services:
|
|||||||
OLLAMA_URL: http://ollama:11434/api/chat
|
OLLAMA_URL: http://ollama:11434/api/chat
|
||||||
OLLAMA_BASE_URL: http://ollama:11434
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
OLLAMA_MODEL: qwen2.5:3b
|
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"]
|
command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
ports:
|
ports:
|
||||||
- "8080:8000"
|
- "8080:8000"
|
||||||
|
|||||||
@@ -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}
|
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
|
||||||
reports which of preprocessing/ocr/extracting is currently running, and
|
reports which of preprocessing/ocr/extracting is currently running, and
|
||||||
the final result once done. Job state lives in memory only - this is a
|
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
|
personal, single-instance server with no need for persistence here (the
|
||||||
here (the Android app is the system of record for confirmed receipts).
|
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 os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -21,17 +26,34 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile
|
from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.security.api_key import APIKeyHeader
|
||||||
|
|
||||||
from adaptive import preprocess_and_ocr_adaptive
|
from adaptive import preprocess_and_ocr_adaptive
|
||||||
from extract import structure
|
from extract import structure
|
||||||
|
|
||||||
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
|
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")
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
||||||
|
API_KEY = os.environ.get("FUELISH_API_KEY", "")
|
||||||
|
|
||||||
app = FastAPI(title="fuelish")
|
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: dict[str, dict] = {}
|
||||||
_jobs_lock = threading.Lock()
|
_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)
|
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:
|
async def create_job(image: UploadFile) -> JSONResponse:
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
|
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)
|
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:
|
def get_job(job_id: str) -> dict:
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
job = _jobs.get(job_id)
|
job = _jobs.get(job_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user