fix: merge main (v0.161.0) into classification feature branch

Resolve all 40 merge conflicts from merging origin/main into the
classification feature branch. Key resolutions:

- Auto-generated files (BUILD_DATE, VERSION, etc.): use main's version
- API tokens: take main's version (token expiry, reactivation, hard-delete)
- Auth: take main's Dropbox credential sharing + token expiry checking
- Config: take main's social_auth_dropbox_use_global_credentials option
- Files API: take main's improved duplicate handling + rate limiting
- Models: keep ClassificationRuleModel alongside main's new models
- Mobile: take main's mature implementation
- Templates/translations: take main's versions (device deletion, reactivation keys)
- Migration: renumber 038_add_classification_rules → 039_add_classification_rules
  to chain after main's 038_add_api_token_expires_at
- Requirements: take main's version (adds segno QR library)
- Tests: take main's more complete token tests, keep classification imports
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 13:08:41 +00:00
88 changed files with 5893 additions and 889 deletions
+11
View File
@@ -3,10 +3,21 @@ WORKDIR=/workdir
DATABASE_URL=sqlite:///./app/database.db
REDIS_URL=redis://redis:6379/0
EXTERNAL_HOSTNAME=docuelevate.example.com
# PUBLIC_BASE_URL=https://docuelevate.example.com # Full URL with scheme; required when X-Forwarded-Proto is not forwarded by your proxy
GOTENBERG_URL=http://gotenberg:3000
ALLOW_FILE_DELETE=true # Allow deletion of file records
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
# **Database Connection Pool** (PostgreSQL / MySQL only; ignored for SQLite)
# DB_POOL_SIZE=10 # Persistent connections per worker (default: 10)
# DB_MAX_OVERFLOW=20 # Extra connections under burst (default: 20)
# DB_POOL_TIMEOUT=30 # Seconds to wait for a pool connection (default: 30)
# DB_POOL_RECYCLE=1800 # Recycle connections after N seconds (default: 1800)
# **Per-User Upload Rate Limiting** (health-aware, Redis-backed)
# UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window (default: 20)
# UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds (default: 60)
# **System Reset / Factory Reset**
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
+1 -1
View File
@@ -1 +1 @@
2026-03-17T13:12:02Z
2026-03-20T12:55:52Z
+217
View File
@@ -10,6 +10,223 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## v0.161.0 (2026-03-20)
### Documentation
- Update scaling, health probe, and beat scheduler documentation
([`4d019d5`](https://github.com/christianlouis/DocuElevate/commit/4d019d53d9ad161a68639d6ba2109ba3ac77df34))
### Features
- **scaling**: Enable horizontal scaling for API and worker pods
([`f75b125`](https://github.com/christianlouis/DocuElevate/commit/f75b12599291050f97aa452d980f17547ddd7bd6))
## v0.160.3 (2026-03-20)
### Bug Fixes
- **mobile**: Wire i18n reactivity, translate all screens, sync language with server
([`3b5ca04`](https://github.com/christianlouis/DocuElevate/commit/3b5ca04ebc8b6dd20f877bc4797e364e0997d840))
### Chores
- **mobile**: Upgrade ESLint to v9 with flat config and fix expo-localization version
([`0e6a4c5`](https://github.com/christianlouis/DocuElevate/commit/0e6a4c5084b3704653965f0091c36b9c78b8ad60))
## v0.160.2 (2026-03-20)
### Bug Fixes
- **dropbox**: Fix Invalid redirect_uri error by adding PUBLIC_BASE_URL config and URL-encoding
([`5e3e2b1`](https://github.com/christianlouis/DocuElevate/commit/5e3e2b19997d3af6570fbaa1e75a49cbfe6cf78d))
## v0.160.1 (2026-03-20)
### Bug Fixes
- **mobile**: Update expo-localization version from ~16.0.6 to ~16.1.0
([`78c3717`](https://github.com/christianlouis/DocuElevate/commit/78c3717661923b43f1762fa7728c75e803938fb7))
## v0.160.0 (2026-03-20)
### Bug Fixes
- **mobile**: Address code review feedback - error handling, filename collision, hash display
([`6541529`](https://github.com/christianlouis/DocuElevate/commit/65415292507aa37408428400aef7e87e006abfd0))
### Features
- **mobile**: Add pre-login legal pages, multi-image selection, file detail view, search, i18n, HEIC
support
([`67c17e7`](https://github.com/christianlouis/DocuElevate/commit/67c17e7baa8edf76be394d0aff42c2adeae351e1))
## v0.159.0 (2026-03-19)
### Code Style
- Apply ruff auto-fix
([`910fb29`](https://github.com/christianlouis/DocuElevate/commit/910fb297ba1122b751250b323a330e15e276daf6))
### Features
- **integrations**: Add Dropbox connection test and global-credential sharing
([`d1f9819`](https://github.com/christianlouis/DocuElevate/commit/d1f9819f4e12320bb901b76366fa8d4a8cd23a66))
## v0.158.4 (2026-03-19)
### Bug Fixes
- **ui**: Show proper error when signup username has invalid characters
([`689c616`](https://github.com/christianlouis/DocuElevate/commit/689c616e448bf3edfcb91b347ae9f6fac43b1983))
## v0.158.3 (2026-03-19)
### Bug Fixes
- **upload**: Reject exact duplicates at upload time and prevent duplicate mobile uploads
([`d5c18cc`](https://github.com/christianlouis/DocuElevate/commit/d5c18ccf07efa28532724a78e63cc6bd7eab9515))
### Refactoring
- **mobile**: Extract normalizeFileUri to shared utility module
([`ec88221`](https://github.com/christianlouis/DocuElevate/commit/ec882214e29d22da9d8025ea857ba754fbddae37))
## v0.158.2 (2026-03-19)
### Bug Fixes
- **mobile**: Add user feedback when server URL is unavailable
([`1366317`](https://github.com/christianlouis/DocuElevate/commit/136631762bc81399aecd5766663da7d923cba985))
- **mobile**: Apple App Store compliance fixes
([`5c15a23`](https://github.com/christianlouis/DocuElevate/commit/5c15a2395a0dcfa5fe5cf1a34a98ae743d29eabc))
- **mobile**: Fix file sharing deep-link conflicts and add MIME type inference
([`1559686`](https://github.com/christianlouis/DocuElevate/commit/1559686f903808e2ab4547f21575460aaeca839e))
- **mobile**: Fix shared file upload hanging by copying to cache
([`f549505`](https://github.com/christianlouis/DocuElevate/commit/f549505bfd625100e77a170cfcae372913680835))
### Documentation
- Add Apple App Store Compliance audit report
([`1572f32`](https://github.com/christianlouis/DocuElevate/commit/1572f322d72583c45c88e55ea51b2456e548de4c))
### Refactoring
- **mobile**: Extract shared MIME type utility and improve error handling
([`cfe83d7`](https://github.com/christianlouis/DocuElevate/commit/cfe83d7efa7f93ca51bfcac417cfafc650bbc36b))
## v0.158.1 (2026-03-19)
### Bug Fixes
- **mobile**: Add shared file to ShareContext directly in +not-found.tsx
([`71a7a57`](https://github.com/christianlouis/DocuElevate/commit/71a7a57adc1bb6de88c060dba563afa433283e38))
## v0.158.0 (2026-03-19)
### Bug Fixes
- Address code review feedback (assertion, exc_info logging)
([`faa68ad`](https://github.com/christianlouis/DocuElevate/commit/faa68adaa143774a8757fda4fd0248cc3aef551e))
- **config**: Add SETTING_METADATA for db pool and upload rate limit settings
([`1d7286c`](https://github.com/christianlouis/DocuElevate/commit/1d7286c4c68de903951d9819bcb58ae1300dab7b))
- **config**: Remove duplicate dictionary keys and class fields from merge
([`d34b8bc`](https://github.com/christianlouis/DocuElevate/commit/d34b8bceb9f96719bf5c922156d5487a4c6426b4))
- **db**: Use NullPool for SQLite and expose pool tuning settings
([`56bf665`](https://github.com/christianlouis/DocuElevate/commit/56bf66539757cf1d98fe251ad64fb75b481462ef))
- **tests**: Add docstring to rate limiter no-op override
([`c9f9001`](https://github.com/christianlouis/DocuElevate/commit/c9f900124407baa230b38f6e1d0f5ee78e0bedcc))
- **tests**: Disable upload rate limiter in test client fixture
([`6bf121f`](https://github.com/christianlouis/DocuElevate/commit/6bf121f02f2c32f7b73435f42d30b9681710089e))
### Features
- **api**: Add per-user health-aware upload rate limiting
([`571cc81`](https://github.com/christianlouis/DocuElevate/commit/571cc817893a1f4cbd7cfbee7f1c46ca1a4f14d8))
## v0.157.2 (2026-03-19)
### Bug Fixes
- **ui**: Improve devices page table layout to prevent horizontal scrolling
([`b12e891`](https://github.com/christianlouis/DocuElevate/commit/b12e8916824273eefb85f0bb498d4991e8c66d5e))
## v0.157.1 (2026-03-19)
### Bug Fixes
- **mobile**: Resolve iOS "unmatched route docuelevate://" error in Open In share flow
([`f2b7db8`](https://github.com/christianlouis/DocuElevate/commit/f2b7db88ba86e894a9bf2e4e7d6a54cd7f423194))
## v0.157.0 (2026-03-19)
### Features
- **api**: Allow disabled tokens/devices to be deleted & reactivated; add token lifetime
([`e4749b4`](https://github.com/christianlouis/DocuElevate/commit/e4749b4e7cdd78ab95627736d3fcd84fecc55e53))
## v0.156.3 (2026-03-18)
### Bug Fixes
- **auth**: Exempt /api/qr-auth/claim from CSRF to fix mobile QR login
([`a4aaebf`](https://github.com/christianlouis/DocuElevate/commit/a4aaebfe6649ef051790b6af24e0739bdd9ecfed))
### Documentation
- **changelog**: Update changelog [skip ci]
([`d8d2016`](https://github.com/christianlouis/DocuElevate/commit/d8d2016f8597f775cdf3848e7baeaae98cb998b0))
## Unreleased
## v0.156.2 (2026-03-18)
### Bug Fixes
- **qr-login**: Render QR code server-side using segno instead of CDN JS library
([`a8eb650`](https://github.com/christianlouis/DocuElevate/commit/a8eb6504ac100c14b10f57511fde8f1202deeffe))
### Chores
- Initial plan for server-side QR code rendering
([`6727253`](https://github.com/christianlouis/DocuElevate/commit/6727253958a6ae8436fc1184736ec43eef2ab820))
- Remove accidentally committed =1.6.0 file
([`ba8c88b`](https://github.com/christianlouis/DocuElevate/commit/ba8c88bc17d3245cba6079ac5ba3be5fddf36366))
## v0.156.1 (2026-03-18)
### Bug Fixes
- Add missing SETTING_METADATA entries for db pool and upload rate limit settings
([`dc0a19b`](https://github.com/christianlouis/DocuElevate/commit/dc0a19bd118d3503ad50608f55fb0bc4ce104948))
## v0.156.0 (2026-03-17)
### Bug Fixes
+1 -1
View File
@@ -1 +1 @@
a3c657b
91eecd9
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.156.0
Build Date: 2026-03-17T13:12:02Z
Git Commit: a3c657b947d774255ede33bcf6143ab60c69d1ac
Git Short SHA: a3c657b
Version: 0.161.0
Build Date: 2026-03-20T12:55:52Z
Git Commit: 91eecd93963e31b06c0dcf76aaf3fb1cfd242d89
Git Short SHA: 91eecd9
Git Branch: main
Commit Date: 2026-03-17T14:11:41+01:00
Build Timestamp: 2026-03-17T13:12:02Z
Commit Date: 2026-03-20T13:55:31+01:00
Build Timestamp: 2026-03-20T12:55:52Z
==============================
+1 -1
View File
@@ -1 +1 @@
0.156.0
0.161.0
+68 -11
View File
@@ -13,7 +13,7 @@ plaintext is returned exactly once at creation time.
import hashlib
import logging
import secrets
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -105,6 +105,7 @@ def _token_to_dict(t: ApiToken) -> dict[str, Any]:
"last_used_ip": t.last_used_ip,
"created_at": t.created_at,
"revoked_at": t.revoked_at,
"expires_at": t.expires_at,
}
@@ -117,6 +118,12 @@ class TokenCreate(BaseModel):
"""Schema for creating a new API token."""
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label for the token")
expires_in_days: int | None = Field(
default=None,
ge=1,
le=3650, # Maximum 10 years; keeps tokens from being effectively permanent while allowing long-lived CI/CD tokens.
description="Optional lifetime in days. If omitted the token never expires.",
)
class TokenResponse(BaseModel):
@@ -130,6 +137,7 @@ class TokenResponse(BaseModel):
last_used_ip: str | None
created_at: datetime | None
revoked_at: datetime | None
expires_at: datetime | None
model_config = {"from_attributes": True}
@@ -160,11 +168,16 @@ async def create_token(
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total
expires_at = None
if body.expires_in_days is not None:
expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days)
db_token = ApiToken(
owner_id=owner_id,
name=body.name,
token_hash=token_hash_value,
token_prefix=prefix,
expires_at=expires_at,
)
try:
db.add(db_token)
@@ -185,6 +198,7 @@ async def create_token(
"last_used_ip": db_token.last_used_ip,
"created_at": db_token.created_at,
"revoked_at": db_token.revoked_at,
"expires_at": db_token.expires_at,
"token": plaintext,
}
@@ -235,30 +249,73 @@ async def list_mobile_tokens(
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
async def revoke_token(
async def revoke_or_delete_token(
token_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Revoke (soft-delete) an API token.
"""Revoke or permanently delete an API token.
The token row is kept for audit purposes but marked inactive with a
``revoked_at`` timestamp.
* **Active token** soft-revoked: the row is kept for audit purposes
but marked inactive with a ``revoked_at`` timestamp.
* **Already-revoked token** hard-deleted: the row is permanently
removed from the database.
"""
db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first()
if not db_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
if not db_token.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already revoked")
if db_token.is_active:
# Soft-revoke the active token.
try:
db_token.is_active = False
db_token.revoked_at = datetime.now(timezone.utc)
db.commit()
except Exception:
db.rollback()
raise
logger.info("API token revoked: id=%s owner=%s", token_id, owner_id)
return {"detail": "Token revoked"}
# Hard-delete an already-revoked token.
try:
db_token.is_active = False
db_token.revoked_at = datetime.now(timezone.utc)
db.delete(db_token)
db.commit()
except Exception:
db.rollback()
raise
logger.info("API token permanently deleted: id=%s owner=%s", token_id, owner_id)
return {"detail": "Token deleted"}
logger.info("API token revoked: id=%s owner=%s", token_id, owner_id)
return {"detail": "Token revoked"}
@router.post("/{token_id}/reactivate", status_code=status.HTTP_200_OK, response_model=TokenResponse)
async def reactivate_token(
token_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Reactivate a previously revoked API token.
Clears the ``revoked_at`` timestamp and sets ``is_active`` back to
``True``. The token can be used for authentication again immediately.
If the token had an ``expires_at`` in the past the caller should
consider re-creating a new token instead.
"""
db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first()
if not db_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
if db_token.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already active")
try:
db_token.is_active = True
db_token.revoked_at = None
db.commit()
db.refresh(db_token)
except Exception:
db.rollback()
raise
logger.info("API token reactivated: id=%s owner=%s", token_id, owner_id)
return _token_to_dict(db_token)
+61
View File
@@ -21,6 +21,67 @@ _DEFAULT_REDIS_URL = "redis://localhost:6379/0"
router = APIRouter()
# ---------------------------------------------------------------------------
# Unauthenticated probe endpoints for Kubernetes liveness / readiness checks.
# These intentionally skip authentication so that kubelet can reach them
# without credentials. They live under /diagnostic/healthz/* so that the
# existing authenticated /diagnostic/health endpoint is unaffected.
# ---------------------------------------------------------------------------
@router.get("/diagnostic/healthz/live")
async def liveness_probe() -> JSONResponse:
"""Lightweight liveness probe for Kubernetes.
Returns **200 OK** as long as the process is running. Kubernetes uses
this to decide whether to *restart* the container — it should therefore
be as cheap as possible and **never** check external dependencies.
**Authentication:** None (designed for kubelet probes).
"""
return JSONResponse(content={"status": "ok"}, status_code=200)
@router.get("/diagnostic/healthz/ready")
async def readiness_probe() -> JSONResponse:
"""Readiness probe for Kubernetes.
Verifies that the application can serve traffic by checking the database
and Redis. Kubernetes uses this to decide whether to *route traffic* to
the pod.
Returns **200 OK** when all critical subsystems are reachable, or
**503 Service Unavailable** when the database is down.
**Authentication:** None (designed for kubelet probes).
"""
checks: dict[str, dict[str, str]] = {}
db_ok = False
# ── Database check ─────────────────────────────────────────────────
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
checks["database"] = {"status": "ok"}
db_ok = True
except Exception as exc:
logger.warning("Readiness probe: database check failed: %s", exc)
checks["database"] = {"status": "error", "detail": str(exc)}
# ── Redis check ────────────────────────────────────────────────────
try:
redis_url = settings.redis_url or _DEFAULT_REDIS_URL
r = redis_lib.from_url(redis_url, socket_connect_timeout=2, socket_timeout=2)
r.ping()
checks["redis"] = {"status": "ok"}
except Exception as exc:
logger.warning("Readiness probe: Redis check failed: %s", exc)
checks["redis"] = {"status": "error", "detail": str(exc)}
http_status = 503 if not db_ok else 200
overall = "ready" if db_ok else "not_ready"
return JSONResponse(content={"status": overall, "checks": checks}, status_code=http_status)
@router.get("/diagnostic/health")
@require_login
+88
View File
@@ -5,6 +5,7 @@ Dropbox API endpoints
import logging
import os
from typing import Annotated, Optional
from urllib.parse import quote
import httpx
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
@@ -23,6 +24,93 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _build_dropbox_redirect_uri(request: Request) -> str:
"""Build the Dropbox OAuth callback redirect URI.
Uses ``PUBLIC_BASE_URL`` when configured (recommended for deployments behind
a reverse proxy that doesn't forward ``X-Forwarded-Proto``). Falls back to
deriving the URI from the incoming request's scheme and host headers.
"""
if settings.public_base_url:
return settings.public_base_url.rstrip("/") + "/dropbox-callback"
return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback"
@router.get("/dropbox/global-authorize-url")
@require_login
async def dropbox_global_authorize_url(request: Request):
"""Return the Dropbox OAuth authorization URL using the global app credentials.
This endpoint is used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS``
is enabled so that users can authorize their personal Dropbox integration without
needing to supply their own app key/secret. Only the public ``app_key`` is
embedded in the URL; the ``app_secret`` is never sent to the browser.
"""
if not settings.dropbox_allow_global_credentials_for_integrations:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Global credentials for integrations are not enabled",
)
if not settings.dropbox_app_key or not settings.dropbox_app_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Global Dropbox credentials are not configured",
)
redirect_uri = _build_dropbox_redirect_uri(request)
authorize_url = (
"https://www.dropbox.com/oauth2/authorize"
f"?client_id={settings.dropbox_app_key}"
"&response_type=code"
"&token_access_type=offline"
f"&redirect_uri={quote(redirect_uri, safe='')}"
)
return {"authorize_url": authorize_url}
@router.post("/dropbox/exchange-token-global")
@require_login
async def exchange_dropbox_token_global(
request: Request,
code: Annotated[str, Form(...)],
redirect_uri: Annotated[str, Form(...)],
):
"""Exchange an authorization code using the global Dropbox app credentials.
Used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS`` is enabled so
that the ``app_secret`` is never exposed to the browser. Only the OAuth code
and redirect URI need to be supplied by the client.
"""
if not settings.dropbox_allow_global_credentials_for_integrations:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Global credentials for integrations are not enabled",
)
if not settings.dropbox_app_key or not settings.dropbox_app_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Global Dropbox credentials are not configured",
)
token_url = "https://api.dropboxapi.com/oauth2/token"
payload = {
"client_id": settings.dropbox_app_key,
"client_secret": settings.dropbox_app_secret,
"code": code,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
}
token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload)
return {
"refresh_token": token_data["refresh_token"],
"access_token": token_data["access_token"],
"expires_in": token_data.get("expires_in", 14400),
# Return the public app_key so the callback can store it in the integration
"app_key": settings.dropbox_app_key,
}
@router.post("/dropbox/exchange-token")
@require_login
async def exchange_dropbox_token(
+64 -22
View File
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.middleware.upload_rate_limit import require_upload_rate_limit
from app.models import FileProcessingStep, FileRecord, ProcessingLog
from app.tasks.convert_to_pdf import convert_to_pdf
from app.tasks.process_document import process_document
@@ -346,7 +347,9 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
try:
# Find all file records
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -385,7 +388,9 @@ def bulk_reprocess_files(request: Request, file_ids: List[int], db: DbSession):
"""
try:
# Find all file records
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -457,7 +462,9 @@ def bulk_reprocess_files_cloud_ocr(request: Request, file_ids: List[int], db: Db
Useful for re-running OCR on files with poor text quality or missing OCR text.
"""
try:
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -537,7 +544,9 @@ def bulk_download_files(request: Request, file_ids: List[int], db: DbSession):
Files not found on disk are silently skipped.
"""
try:
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -619,7 +628,9 @@ def reprocess_single_file(request: Request, file_id: int, db: DbSession):
"""
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -675,7 +686,9 @@ def reprocess_with_cloud_ocr(request: Request, file_id: int, db: DbSession):
"""
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -938,7 +951,9 @@ def retry_subtask(
"""
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1080,7 +1095,9 @@ def get_file_preview(
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1160,7 +1177,9 @@ def download_file(
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1249,7 +1268,12 @@ async def _save_upload_file_chunks(file: UploadFile, target_path: str, max_size:
def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: str) -> dict | None:
"""Check for an exact duplicate of the uploaded file and return a warning if found."""
"""Check for an exact duplicate of the uploaded file.
Returns a dict with duplicate info when the file's SHA-256 hash matches an
already-processed document, or ``None`` when no duplicate is found (or
deduplication is disabled).
"""
if not settings.enable_deduplication:
return None
@@ -1268,8 +1292,8 @@ def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: s
"original_file_id": existing.id,
"original_filename": existing.original_filename,
"message": (
"This file appears to be an exact duplicate of an already-processed document. "
"It will still be queued but will be flagged as a duplicate."
"This file is an exact duplicate of an already-processed document. "
"It has not been queued for processing again."
),
}
except Exception as e:
@@ -1280,7 +1304,12 @@ def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: s
@router.post("/ui-upload")
@require_login
async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...)):
async def ui_upload(
request: Request,
db: DbSession,
file: UploadFile = File(...),
_rate_ok: None = Depends(require_upload_rate_limit),
):
"""Endpoint to accept a user-uploaded file and enqueue it for processing."""
workdir = settings.workdir
@@ -1366,6 +1395,25 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
file_size = written_size
# ── Early duplicate rejection ──────────────────────────────────────────
# Check for exact duplicates (same SHA-256 hash) BEFORE enqueuing a
# processing task. When deduplication is enabled and the file already
# exists, we skip processing entirely, clean up the temp file, and
# return the existing file's information to the caller.
exact_duplicate = _check_for_exact_duplicate(db, target_path, safe_filename)
if exact_duplicate:
# Remove the just-saved temp file — it's a duplicate.
try:
os.remove(target_path)
except OSError:
pass
return {
"status": "duplicate",
"original_filename": safe_filename,
"stored_filename": target_filename,
"duplicate_of": exact_duplicate,
}
# Determine if the file is a PDF or needs conversion
mime_type, _ = mimetypes.guess_type(target_path)
file_ext = os.path.splitext(target_path)[1].lower()
@@ -1429,6 +1477,8 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
".tif",
".webp",
".svg",
".heic",
".heif",
}:
# If it's an image, convert to PDF first
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
@@ -1442,20 +1492,12 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion")
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
# Check for exact duplicates (same SHA-256 hash) before returning.
# This gives the caller an immediate warning without waiting for the pipeline.
# Only performed when deduplication is enabled in settings.
exact_duplicate_warning = _check_for_exact_duplicate(db, target_path, safe_filename)
response: dict = {
return {
"task_id": task.id,
"status": "queued",
"original_filename": safe_filename,
"stored_filename": target_filename,
}
if exact_duplicate_warning:
response["duplicate_warning"] = exact_duplicate_warning
return response
# ---------------------------------------------------------------------------
+57
View File
@@ -32,6 +32,21 @@ from app.utils.encryption import decrypt_value, encrypt_value
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
# Optional Dropbox SDK — imported at module level so tests can patch it cleanly.
try:
import dropbox as dbx_lib
from dropbox.exceptions import AuthError as _DropboxAuthError
from dropbox.exceptions import BadInputError as _DropboxBadInputError
except ImportError: # pragma: no cover
dbx_lib = None # type: ignore[assignment]
class _DropboxAuthError(Exception): # type: ignore[no-redef]
"""Stub — only used when the dropbox package is missing."""
class _DropboxBadInputError(Exception): # type: ignore[no-redef]
"""Stub — only used when the dropbox package is missing."""
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/integrations", tags=["integrations"])
@@ -550,6 +565,47 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An
return {"success": False, "message": "S3 connection failed"}
def _test_dropbox_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
"""Test a Dropbox connection by verifying OAuth credentials via the Dropbox API."""
if dbx_lib is None:
return {"success": False, "message": "dropbox package is not installed"} # pragma: no cover
creds = credentials or {}
app_key = creds.get("app_key", "")
app_secret = creds.get("app_secret", "")
refresh_token = creds.get("refresh_token", "")
if not refresh_token:
return {"success": False, "message": "Missing required credential: refresh_token"}
if not app_key or not app_secret:
return {"success": False, "message": "Missing required credentials: app_key and app_secret"}
try:
dbx = dbx_lib.Dropbox(
app_key=app_key,
app_secret=app_secret,
oauth2_refresh_token=refresh_token,
)
account = dbx.users_get_current_account()
display_name = getattr(account, "name", None)
name_str = ""
if display_name:
name_str = f" ({getattr(display_name, 'display_name', '') or ''})"
return {"success": True, "message": f"Dropbox connection successful{name_str}"}
except _DropboxAuthError as exc:
logger.warning("Dropbox auth error: %s", exc)
return {
"success": False,
"message": "Dropbox authentication failed — check app_key, app_secret, and refresh_token",
}
except _DropboxBadInputError as exc:
logger.warning("Dropbox bad input error: %s", exc)
return {"success": False, "message": "Dropbox connection failed — invalid credentials format"}
except Exception as exc: # noqa: BLE001
logger.warning("Dropbox connection error: %s", exc)
return {"success": False, "message": "Dropbox connection failed — check credentials and network connectivity"}
def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
"""Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND."""
import urllib.request
@@ -596,6 +652,7 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
_CONNECTION_TESTERS: dict[str, Any] = {
IntegrationType.DROPBOX: _test_dropbox_connection,
IntegrationType.IMAP: _test_imap_connection,
IntegrationType.S3: _test_s3_connection,
IntegrationType.WEBDAV: _test_webdav_connection,
+23 -8
View File
@@ -120,6 +120,7 @@ class WhoAmIResponse(BaseModel):
email: str | None
avatar_url: str | None
is_admin: bool
preferred_language: str | None
# ---------------------------------------------------------------------------
@@ -273,31 +274,44 @@ async def list_devices(
return [_device_to_response(d) for d in devices]
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/devices/{device_id}", status_code=status.HTTP_200_OK)
@require_login
async def deactivate_device(
request: Request,
device_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> None:
"""Deactivate a push-notification device registration.
) -> dict[str, str]:
"""Deactivate or permanently delete a push-notification device registration.
The device record is kept for audit purposes but will no longer receive
push notifications.
* **Active device** soft-deactivated: the record is kept for audit
purposes but will no longer receive push notifications.
* **Already-inactive device** hard-deleted: the record is permanently
removed from the database.
"""
device = db.get(MobileDevice, device_id)
if not device or device.owner_id != owner_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
device.is_active = False
if device.is_active:
device.is_active = False
try:
db.commit()
except Exception:
db.rollback()
raise
logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id)
return {"detail": "Device deactivated"}
# Hard-delete an already-inactive device.
try:
db.delete(device)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id)
logger.info("Mobile device permanently deleted: id=%s owner=%s", device_id, owner_id)
return {"detail": "Device deleted"}
@router.get("/whoami", response_model=WhoAmIResponse)
@@ -344,4 +358,5 @@ async def whoami(
"email": email,
"avatar_url": avatar_url,
"is_admin": is_admin,
"preferred_language": profile.preferred_language if profile else None,
}
+28
View File
@@ -19,10 +19,13 @@ Security properties:
from __future__ import annotations
import base64
import io
import logging
from datetime import datetime
from typing import Annotated, Any
import segno
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -72,6 +75,7 @@ class CreateChallengeResponse(BaseModel):
expires_at: datetime
ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
qr_payload: str = Field(description="The string to encode in the QR code.")
qr_code_svg: str = Field(description="Base64-encoded SVG data URI of the QR code, ready for use in an <img> src.")
class ChallengeStatusResponse(BaseModel):
@@ -106,6 +110,29 @@ class ClaimChallengeResponse(BaseModel):
created_at: datetime
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# QR code rendering parameters
_QR_ERROR_LEVEL = "M" # Medium error correction (~15% recovery); sufficient for on-screen display
_QR_SCALE = 4 # Each QR module is rendered as 4×4 SVG pixels
def _generate_qr_svg(payload: str) -> str:
"""Generate a QR code for *payload* and return it as a base64 SVG data URI.
Using ``segno`` (pure-Python, no Pillow dependency) and SVG output so the
QR code scales crisply at any resolution without requiring a canvas or any
client-side JavaScript library.
"""
qr = segno.make(payload, error=_QR_ERROR_LEVEL)
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=_QR_SCALE, xmldecl=False, svgclass=None, lineclass=None, omitsize=True)
svg_bytes = buf.getvalue()
return "data:image/svg+xml;base64," + base64.b64encode(svg_bytes).decode("ascii")
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -143,6 +170,7 @@ async def create_challenge(
"expires_at": challenge.expires_at,
"ttl_seconds": ttl_seconds,
"qr_payload": qr_payload,
"qr_code_svg": _generate_qr_svg(qr_payload),
}
+7 -2
View File
@@ -11,11 +11,12 @@ from typing import Optional
import aiofiles
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, HttpUrl, field_validator
from app.auth import require_login
from app.config import settings
from app.middleware.upload_rate_limit import require_upload_rate_limit
from app.tasks.process_document import process_document
from app.utils.allowed_types import ALLOWED_MIME_TYPES
from app.utils.filename_utils import sanitize_filename
@@ -107,7 +108,11 @@ def validate_file_type(content_type: str, filename: str) -> bool:
@router.post("/process-url")
@require_login
async def process_url(request: Request, url_request: URLUploadRequest):
async def process_url(
request: Request,
url_request: URLUploadRequest,
_rate_ok: None = Depends(require_upload_rate_limit),
):
"""
Download a file from a URL and enqueue it for processing.
+20 -3
View File
@@ -103,11 +103,18 @@ if AUTH_ENABLED and settings.social_auth_apple_enabled:
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
if settings.social_auth_dropbox_client_id and settings.social_auth_dropbox_client_secret:
# Determine which credentials to use for Dropbox social login
_dropbox_client_id = settings.social_auth_dropbox_client_id
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
if settings.social_auth_dropbox_use_global_credentials and not _dropbox_client_id:
_dropbox_client_id = settings.dropbox_app_key
_dropbox_client_secret = settings.dropbox_app_secret
if _dropbox_client_id and _dropbox_client_secret:
oauth.register(
name="dropbox",
client_id=settings.social_auth_dropbox_client_id,
client_secret=settings.social_auth_dropbox_client_secret,
client_id=_dropbox_client_id,
client_secret=_dropbox_client_secret,
authorize_url="https://www.dropbox.com/oauth2/authorize",
access_token_url="https://api.dropboxapi.com/oauth2/token",
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
@@ -186,6 +193,16 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
logger.debug("[AUTH] _resolve_bearer_user: no active API token matched the provided hash")
return None
# Reject tokens that have passed their optional expiry.
if db_token.expires_at is not None:
now_utc = datetime.now(timezone.utc)
expires_aware = db_token.expires_at
if expires_aware.tzinfo is None:
expires_aware = expires_aware.replace(tzinfo=timezone.utc)
if now_utc > expires_aware:
logger.debug("[AUTH] _resolve_bearer_user: API token id=%s has expired", db_token.id)
return None
logger.debug(
"[AUTH] _resolve_bearer_user: matched API token id=%s owner=%s",
db_token.id,
+62
View File
@@ -13,6 +13,24 @@ class Settings(BaseSettings):
database_url: str
redis_url: str
# Database connection-pool tuning (ignored for SQLite, which uses NullPool).
db_pool_size: int = Field(
default=10,
description="Number of persistent connections kept in the pool per worker process.",
)
db_max_overflow: int = Field(
default=20,
description="Additional connections allowed beyond db_pool_size under burst load.",
)
db_pool_timeout: int = Field(
default=30,
description="Seconds to wait for a connection from the pool before raising a TimeoutError.",
)
db_pool_recycle: int = Field(
default=1800,
description="Recycle (close and reopen) connections after this many seconds to avoid stale connections.",
)
openai_api_key: str
openai_base_url: str = "https://api.openai.com/v1" # Default to OpenAI's endpoint
openai_model: str = "gpt-4o-mini" # Default model
@@ -102,6 +120,16 @@ class Settings(BaseSettings):
dropbox_app_secret: Optional[str] = None
dropbox_folder: Optional[str] = None
dropbox_refresh_token: Optional[str] = None
dropbox_allow_global_credentials_for_integrations: bool = Field(
default=False,
description=(
"When True, users may authorize their personal Dropbox integrations using the global "
"DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without "
"needing to create their own Dropbox app. The Dropbox OAuth flow is initiated "
"server-side so the app secret is never exposed to the browser. "
"Default: False (each user must supply their own app credentials)."
),
)
# Making Nextcloud optional
nextcloud_enabled: bool = Field(
@@ -165,6 +193,16 @@ class Settings(BaseSettings):
google_docai_processor_id: Optional[str] = None
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
external_hostname: str = "localhost" # Default to localhost
public_base_url: Optional[str] = Field(
default=None,
description=(
"The full public base URL of the application, including scheme "
"(e.g., 'https://docuelevate.example.com'). "
"When set, this overrides the auto-detected URL for OAuth redirect URIs. "
"This is required when the application is behind a reverse proxy that does "
"not forward X-Forwarded-Proto headers correctly."
),
)
# ---------------------------------------------------------------------------
# Document Translation Settings
@@ -299,6 +337,16 @@ class Settings(BaseSettings):
social_auth_dropbox_enabled: bool = False
social_auth_dropbox_client_id: Optional[str] = None
social_auth_dropbox_client_secret: Optional[str] = None
social_auth_dropbox_use_global_credentials: bool = Field(
default=False,
description=(
"When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET "
"credentials (the storage integration credentials) instead of requiring separate "
"SOCIAL_AUTH_DROPBOX_CLIENT_ID / SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. "
"Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and the global Dropbox app credentials to be set. "
"Default: False."
),
)
# Local user signup
allow_local_signup: bool = Field(
@@ -1115,6 +1163,20 @@ class Settings(BaseSettings):
),
)
# Per-user upload rate limiting (health-aware, Redis-backed sliding window)
upload_rate_limit_per_user: int = Field(
default=20,
description=(
"Maximum number of file uploads allowed per user within the sliding window. "
"The effective limit may be reduced dynamically when the system is under heavy load "
"(high queue depth or CPU usage). Set to 0 to disable per-user upload rate limiting."
),
)
upload_rate_limit_window: int = Field(
default=60,
description="Sliding window size in seconds for per-user upload rate limiting (default: 60).",
)
# Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md)
# Protects against DoS attacks and API abuse
rate_limiting_enabled: bool = Field(
+31 -2
View File
@@ -10,6 +10,7 @@ from typing import Any
from sqlalchemy import create_engine, exc
from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import Session, declarative_base, sessionmaker
from sqlalchemy.pool import NullPool, QueuePool
from app.config import settings
@@ -17,9 +18,37 @@ logger = logging.getLogger(__name__)
Base = declarative_base()
# Parse the DATABASE_URL
# ---------------------------------------------------------------------------
# Engine construction
# ---------------------------------------------------------------------------
DB_URL = settings.database_url
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
_parsed_url = make_url(DB_URL)
_connect_args: dict[str, Any] = {}
_engine_kwargs: dict[str, Any] = {
"pool_pre_ping": True, # detect stale / dropped connections before use
}
if _parsed_url.get_backend_name() == "sqlite":
# SQLite does not benefit from connection pooling and is prone to
# QueuePool exhaustion under concurrent access. NullPool opens a fresh
# connection for each request and closes it immediately afterwards,
# completely avoiding the "QueuePool limit reached" TimeoutError.
_connect_args["check_same_thread"] = False
_engine_kwargs["poolclass"] = NullPool
else:
# PostgreSQL / MySQL — use a bounded QueuePool with configurable limits.
_engine_kwargs["poolclass"] = QueuePool
_engine_kwargs.update(
{
"pool_size": settings.db_pool_size,
"max_overflow": settings.db_max_overflow,
"pool_timeout": settings.db_pool_timeout,
"pool_recycle": settings.db_pool_recycle,
}
)
engine = create_engine(DB_URL, connect_args=_connect_args, **_engine_kwargs)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+7
View File
@@ -20,6 +20,9 @@ How it works:
Exempt paths (CSRF is not checked even for state-changing methods):
- ``/oauth-callback`` OAuth 2.0 callback; protected by the ``state`` parameter.
- ``/api/qr-auth/claim`` Called by the unauthenticated mobile app; the
cryptographically-random, single-use challenge token provides equivalent
protection.
"""
import logging
@@ -39,6 +42,10 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
# their own replay-protection mechanism).
CSRF_EXEMPT_PATHS = {
"/oauth-callback",
# The mobile app calls this endpoint without a browser session/CSRF token.
# The cryptographically-random, single-use challenge token already provides
# equivalent protection against cross-site request forgery.
"/api/qr-auth/claim",
}
+290
View File
@@ -0,0 +1,290 @@
"""Per-user, health-aware upload rate limiter for DocuElevate.
This module provides a FastAPI dependency that enforces per-user upload rate
limits using a Redis-backed sliding window counter. The effective limit is
dynamically reduced when the system is under heavy load (high Celery queue
depth or elevated CPU load average), ensuring the server remains responsive
to all users even during bulk-upload scenarios.
Usage in an endpoint::
from app.middleware.upload_rate_limit import require_upload_rate_limit
@router.post("/ui-upload")
@require_login
async def ui_upload(
request: Request,
_rate_ok: None = Depends(require_upload_rate_limit),
...
):
...
See ``docs/ConfigurationGuide.md`` for the configuration options
(``UPLOAD_RATE_LIMIT_PER_USER``, ``UPLOAD_RATE_LIMIT_WINDOW``).
"""
from __future__ import annotations
import logging
import os
import time
from typing import Any
import redis
from fastapi import HTTPException, Request, status
from app.config import settings
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Redis key prefix
# ---------------------------------------------------------------------------
_KEY_PREFIX = "docuelevate:upload_rate"
# ---------------------------------------------------------------------------
# Health-check queue names (Celery defaults used by DocuElevate)
# ---------------------------------------------------------------------------
_CELERY_QUEUES = ("document_processor", "default", "celery")
# ---------------------------------------------------------------------------
# Singleton Redis client (lazy-initialised; fail-open when unavailable)
# ---------------------------------------------------------------------------
_redis_client: redis.Redis | None = None
def _get_redis() -> redis.Redis | None:
"""Return a shared Redis client, or *None* when Redis is unavailable."""
global _redis_client
if _redis_client is not None:
return _redis_client
try:
_redis_client = redis.Redis.from_url(
settings.redis_url,
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
)
# Quick connectivity check raises on failure.
_redis_client.ping()
return _redis_client
except Exception: # noqa: BLE001
logger.debug("Redis unavailable for upload rate limiter falling back to allow-all", exc_info=True)
_redis_client = None
return None
# ---------------------------------------------------------------------------
# Health metrics helpers
# ---------------------------------------------------------------------------
def _get_queue_depth(r: redis.Redis) -> int:
"""Return the total number of pending tasks across all Celery queues."""
total = 0
for queue_name in _CELERY_QUEUES:
try:
total += r.llen(queue_name)
except Exception: # noqa: BLE001, S110
logger.debug("Could not read queue length for %r", queue_name, exc_info=True)
return total
def _get_cpu_load_ratio() -> float:
"""Return the 1-minute load average divided by the number of CPU cores.
Returns ``0.0`` on platforms that do not support :func:`os.getloadavg`
(e.g. Windows) so that the limiter never penalises on those systems.
"""
try:
load_1m = os.getloadavg()[0]
cpu_count = os.cpu_count() or 1
return load_1m / cpu_count
except (OSError, AttributeError):
return 0.0
def compute_effective_limit(
base_limit: int,
queue_depth: int = 0,
cpu_load_ratio: float = 0.0,
) -> tuple[int, float, str]:
"""Compute the effective upload rate limit based on system health.
The function applies a *reduction factor* (``0.0 < factor 1.0``) to the
configured base limit. Both queue depth and CPU load contribute
independently; the lowest factor wins.
Args:
base_limit: The configured maximum uploads per window.
queue_depth: Total pending tasks in Celery queues.
cpu_load_ratio: 1-minute load average divided by CPU count.
Returns:
A 3-tuple of ``(effective_limit, factor, reason)`` where *reason*
is a human-readable tag for logging.
"""
factor = 1.0
reason = "normal"
# --- Queue-depth thresholds ---
if queue_depth > 200:
factor, reason = min(factor, 0.10), f"critical_queue({queue_depth})"
elif queue_depth > 100:
factor, reason = min(factor, 0.25), f"high_queue({queue_depth})"
elif queue_depth > 50:
factor, reason = min(factor, 0.50), f"moderate_queue({queue_depth})"
# --- CPU-load thresholds ---
if cpu_load_ratio > 3.0:
new_factor = 0.10
if new_factor < factor:
factor, reason = new_factor, f"critical_cpu({cpu_load_ratio:.1f})"
elif cpu_load_ratio > 2.0:
new_factor = 0.25
if new_factor < factor:
factor, reason = new_factor, f"high_cpu({cpu_load_ratio:.1f})"
elif cpu_load_ratio > 1.5:
new_factor = 0.50
if new_factor < factor:
factor, reason = new_factor, f"moderate_cpu({cpu_load_ratio:.1f})"
effective = max(1, int(base_limit * factor))
return effective, factor, reason
# ---------------------------------------------------------------------------
# Core sliding-window check (Redis sorted set)
# ---------------------------------------------------------------------------
def _check_and_record(
r: redis.Redis,
user_id: str,
window: int,
effective_limit: int,
) -> dict[str, Any] | None:
"""Atomically check the user's upload count and record the new upload.
Uses a Redis sorted set where each member is a unique timestamp-based ID
and the score is the Unix timestamp. Entries older than *window* seconds
are pruned on every call so the set never grows unbounded.
Returns:
``None`` if the request is allowed, or a ``dict`` with ``count``,
``limit``, and ``retry_after`` if the limit is exceeded.
"""
key = f"{_KEY_PREFIX}:{user_id}"
now = time.time()
window_start = now - window
pipe = r.pipeline(transaction=True)
# 1. Remove entries outside the window
pipe.zremrangebyscore(key, "-inf", window_start)
# 2. Count current entries
pipe.zcard(key)
# 3. Retrieve the oldest entry's score (to compute retry_after)
pipe.zrange(key, 0, 0, withscores=True)
results = pipe.execute()
current_count: int = results[1]
oldest_entries: list = results[2]
if current_count >= effective_limit:
# Compute how long until the oldest entry expires from the window.
if oldest_entries:
oldest_score = oldest_entries[0][1]
retry_after = max(1, int((oldest_score + window) - now))
else:
retry_after = max(1, window // 2)
return {
"count": current_count,
"limit": effective_limit,
"retry_after": retry_after,
}
# 4. Record this upload (unique member = timestamp with random suffix)
member = f"{now}:{os.urandom(4).hex()}"
pipe2 = r.pipeline(transaction=True)
pipe2.zadd(key, {member: now})
pipe2.expire(key, window + 60) # TTL slightly longer than window
pipe2.execute()
return None
# ---------------------------------------------------------------------------
# FastAPI dependency
# ---------------------------------------------------------------------------
async def require_upload_rate_limit(request: Request) -> None:
"""FastAPI dependency that enforces per-user upload rate limits.
The dependency is designed to **fail open**: if Redis is unavailable the
request is allowed through so that uploads are never blocked by a
monitoring outage.
Raises:
HTTPException: 429 Too Many Requests when the per-user upload limit
is exceeded. The ``Retry-After`` header indicates how many
seconds the client should wait before retrying.
"""
r = _get_redis()
if r is None:
# Redis unavailable fail open.
return
# Identify the user (owner_id for multi-user, IP fallback).
user_id = get_current_owner_id(request)
if not user_id:
user_id = f"ip:{request.client.host}" if request.client else "ip:unknown"
base_limit: int = settings.upload_rate_limit_per_user
window: int = settings.upload_rate_limit_window
# Gather health metrics and compute effective limit.
try:
queue_depth = _get_queue_depth(r)
except Exception: # noqa: BLE001
queue_depth = 0
cpu_load_ratio = _get_cpu_load_ratio()
effective_limit, factor, health_reason = compute_effective_limit(base_limit, queue_depth, cpu_load_ratio)
# Sliding-window check.
try:
rejection = _check_and_record(r, user_id, window, effective_limit)
except Exception as exc: # noqa: BLE001
logger.warning("Upload rate-limit check failed (allowing request): %s", exc)
return
if rejection is not None:
retry_after = rejection["retry_after"]
logger.warning(
"Upload rate limit exceeded: user=%s count=%d/%d window=%ds health=%s retry_after=%ds",
user_id,
rejection["count"],
rejection["limit"],
window,
health_reason,
retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Upload rate limit exceeded ({rejection['count']}/{rejection['limit']} "
f"in {window}s). Retry after {retry_after}s."
),
headers={"Retry-After": str(retry_after)},
)
if factor < 1.0:
logger.info(
"Upload allowed with reduced limit: user=%s effective=%d/%d health=%s",
user_id,
effective_limit,
base_limit,
health_reason,
)
+3
View File
@@ -786,6 +786,9 @@ class ApiToken(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
revoked_at = Column(DateTime(timezone=True), nullable=True)
# Optional expiry: if set, the token is rejected after this timestamp.
expires_at = Column(DateTime(timezone=True), nullable=True)
class SharedLink(Base):
"""Shareable, time-limited or view-limited document link.
+1 -1
View File
@@ -205,7 +205,7 @@ def convert_to_pdf(
".pdf", # PDF (already in PDF format but can be processed)
}
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg"}
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", ".heic", ".heif"}
HTML_EXTENSIONS = {".html", ".htm"}
+9 -1
View File
@@ -68,6 +68,8 @@ IMAGE_MIME_TYPES: set[str] = {
"image/tiff",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
}
# ---------------------------------------------------------------------------
@@ -124,6 +126,8 @@ ALLOWED_EXTENSIONS: set[str] = {
".tif",
".webp",
".svg",
".heic",
".heif",
# Web
".html",
".htm",
@@ -234,7 +238,7 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
},
"images": {
"label": "Images",
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg)",
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg, .heic, .heif)",
"mime_types": frozenset(
{
"image/jpeg",
@@ -245,6 +249,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
"image/tiff",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
}
),
"extensions": frozenset(
@@ -258,6 +264,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
".tif",
".webp",
".svg",
".heic",
".heif",
}
),
},
+102
View File
@@ -39,6 +39,50 @@ SETTING_METADATA = {
"required": True,
"restart_required": True,
},
"db_pool_size": {
"category": "Core",
"description": (
"Number of persistent database connections kept in the pool per worker process. "
"Ignored for SQLite (which uses NullPool). Default: 10."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"db_max_overflow": {
"category": "Core",
"description": (
"Additional database connections allowed beyond db_pool_size under burst load. "
"Ignored for SQLite. Default: 20."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"db_pool_timeout": {
"category": "Core",
"description": (
"Seconds to wait for a database connection from the pool before raising a TimeoutError. "
"Ignored for SQLite. Default: 30."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"db_pool_recycle": {
"category": "Core",
"description": (
"Recycle (close and reopen) database connections after this many seconds "
"to avoid stale connections. Ignored for SQLite. Default: 1800."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"workdir": {
"category": "Core",
"description": "Working directory for file storage and processing",
@@ -55,6 +99,18 @@ SETTING_METADATA = {
"required": True, # Required for OAuth redirects and external URLs
"restart_required": True,
},
"public_base_url": {
"category": "Core",
"description": (
"Full public base URL including scheme (e.g., https://docuelevate.example.com). "
"When set, overrides auto-detected URLs for OAuth redirect URIs. "
"Required when behind a reverse proxy that does not forward X-Forwarded-Proto."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"debug": {
"category": "Core",
"description": "Enable debug mode for verbose logging",
@@ -326,6 +382,19 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"social_auth_dropbox_use_global_credentials": {
"category": "Social Login",
"description": (
"When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET "
"credentials instead of requiring separate SOCIAL_AUTH_DROPBOX_CLIENT_ID / "
"SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. "
"Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and global Dropbox credentials to be set."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_dropbox_enabled": {
"category": "Social Login",
"description": (
@@ -719,6 +788,18 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"dropbox_allow_global_credentials_for_integrations": {
"category": "Storage Providers",
"description": (
"When True, users may authorize their personal Dropbox integrations using the global "
"DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without "
"needing to create their own Dropbox app."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Storage Providers - Nextcloud
"nextcloud_enabled": {
"category": "Storage Providers",
@@ -2557,6 +2638,27 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
# Per-user upload rate limiting
"upload_rate_limit_per_user": {
"category": "Security",
"description": (
"Maximum number of uploads a single user may submit within upload_rate_limit_window seconds. "
"The health-aware limiter may reduce this dynamically under high Redis queue depth or CPU load. "
"Default: 20."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
"upload_rate_limit_window": {
"category": "Security",
"description": ("Sliding window in seconds over which upload_rate_limit_per_user is enforced. Default: 60."),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Rate Limiting
"rate_limiting_enabled": {
"category": "Security",
+27 -1
View File
@@ -14,6 +14,19 @@ from app.views.base import APIRouter, Depends, get_db, require_login, settings,
router = APIRouter()
def _get_dropbox_callback_url(request: Request) -> str:
"""Return the Dropbox OAuth callback URL.
Uses ``PUBLIC_BASE_URL`` when configured so that the redirect URI displayed
to the user (and registered in the Dropbox developer console) matches the
one used in the OAuth authorization request. Falls back to deriving the URL
from the incoming request when ``PUBLIC_BASE_URL`` is not set.
"""
if settings.public_base_url:
return settings.public_base_url.rstrip("/") + "/dropbox-callback"
return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback"
@router.get("/dropbox-setup")
@require_login
async def dropbox_setup_page(
@@ -30,6 +43,8 @@ async def dropbox_setup_page(
path from the integration's existing config is pre-populated; global
admin credentials are never exposed in this mode.
"""
callback_url = _get_dropbox_callback_url(request)
if integration_id is not None:
owner_id = get_current_owner_id(request)
integration = (
@@ -46,6 +61,12 @@ async def dropbox_setup_page(
cfg = {}
# Support both "folder" (DROPBOX destination) and "folder_path" (WATCH_FOLDER source)
folder_path = cfg.get("folder", cfg.get("folder_path", ""))
# Determine if global credentials are available for users to reuse
global_creds_available = bool(
settings.dropbox_allow_global_credentials_for_integrations
and settings.dropbox_app_key
and settings.dropbox_app_secret
)
return templates.TemplateResponse(
"dropbox.html",
{
@@ -56,9 +77,12 @@ async def dropbox_setup_page(
"integration_name": integration.name,
"integration_type": integration.integration_type,
"folder_path": folder_path,
"app_key_value": "",
# Only expose the public app key (not the secret) when global creds are allowed
"app_key_value": settings.dropbox_app_key if global_creds_available else "",
"app_secret_value": "",
"refresh_token_value": "",
"global_creds_available": global_creds_available,
"callback_url": callback_url,
},
)
@@ -78,6 +102,7 @@ async def dropbox_setup_page(
"integration_id": integration_id,
"integration_name": None,
"integration_type": None,
"callback_url": callback_url,
},
)
@@ -108,5 +133,6 @@ async def dropbox_callback(request: Request, code: str = None, error: str = None
"app_key_value": "", # The callback will prioritize sessionStorage values
"app_secret_value": "", # The callback will prioritize sessionStorage values
"folder_path": "", # The callback will prioritize sessionStorage values
"callback_url": _get_dropbox_callback_url(request),
},
)
+25 -4
View File
@@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: document_api
# No container_name — allows `docker compose up --scale api=N`
restart: always
# We'll keep the code in /app, but set working_dir to the shared data directory
@@ -24,7 +24,7 @@ services:
depends_on:
- redis
- worker
- beat
# Mount the shared working directory for data
volumes:
@@ -34,13 +34,14 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: document_worker
# No container_name — allows `docker compose up --scale worker=N`
restart: always
# same shared working directory
working_dir: /workdir
command: ["celery", "-A", "app.celery_worker", "worker", "-B", "--loglevel=info", "-Q", "document_processor,default,celery"]
# Workers process tasks only — no -B flag (Beat runs in the dedicated beat service)
command: ["celery", "-A", "app.celery_worker", "worker", "--loglevel=info", "-Q", "document_processor,default,celery"]
env_file:
- .env
environment:
@@ -54,6 +55,26 @@ services:
volumes:
- /var/docparse/workdir:/workdir
# Dedicated Celery Beat scheduler — exactly one instance must run at all times.
# Beat publishes periodic tasks to the Redis broker; workers pick them up.
# Do NOT scale this service (replicas must stay at 1).
beat:
build:
context: .
dockerfile: Dockerfile
container_name: document_beat
restart: always
working_dir: /workdir
command: ["celery", "-A", "app.celery_worker", "beat", "--loglevel=info"]
env_file:
- .env
environment:
- PYTHONPATH=/app
depends_on:
- redis
volumes:
- /var/docparse/workdir:/workdir
gotenberg:
image: gotenberg/gotenberg:latest
container_name: gotenberg
+101 -17
View File
@@ -27,9 +27,11 @@ DocuElevate implements rate limiting to protect against abuse and DoS attacks. R
### Default Limits
- **Default endpoints**: 100 requests per minute
- **File upload**: 600 requests per minute
- **File upload**: 600 requests per minute (global) + 20 per user per 60 s (per-user, health-aware)
- **Authentication**: 10 requests per minute
**Per-user upload rate limiting**: Upload endpoints (`/api/ui-upload`, `/api/process-url`) enforce a per-user sliding-window limit that adapts to system load. Under heavy queue depth or high CPU usage, the effective limit is reduced automatically. See the [Configuration Guide](ConfigurationGuide.md#per-user-upload-rate-limiting) for details.
**Note**: Document processing endpoints (OCR, metadata extraction) use built-in queue throttling to control processing rates and prevent upstream API overloads. No additional API-level rate limit is applied to processing endpoints.
### Rate Limit Headers
@@ -53,6 +55,10 @@ RATE_LIMITING_ENABLED=true
RATE_LIMIT_DEFAULT=100/minute
RATE_LIMIT_UPLOAD=600/minute
RATE_LIMIT_AUTH=10/minute
# Per-user upload rate limiting (health-aware)
UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window
UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds
```
See [Configuration Guide](ConfigurationGuide.md) for more details.
@@ -115,7 +121,8 @@ curl -X GET "http://<your-docuelevate-instance>/api/files" \
|--------|----------|-------------|
| `POST` | `/api/api-tokens/` | Create a new token |
| `GET` | `/api/api-tokens/` | List all your tokens |
| `DELETE` | `/api/api-tokens/{id}` | Revoke a token |
| `DELETE` | `/api/api-tokens/{id}` | Revoke (active) or permanently delete (revoked) a token |
| `POST` | `/api/api-tokens/{id}/reactivate` | Reactivate a revoked token |
### Session Authentication
@@ -235,17 +242,33 @@ The DocuElevate browser extension uses this endpoint to send files directly from
**POST** `/api/ui-upload`
Upload one or more files from your computer for processing.
Upload a file from your computer for processing.
**Request**:
- Multipart form data with file(s)
- Multipart form data with a single `file` field
**Response**:
**Response** (new file):
```json
{
"success": true,
"file_ids": [123, 124],
"message": "Files uploaded and queued for processing"
"task_id": "abc-123",
"status": "queued",
"original_filename": "invoice.pdf",
"stored_filename": "a1b2c3d4.pdf"
}
```
**Response** (exact duplicate, when `ENABLE_DEDUPLICATION=True`):
```json
{
"status": "duplicate",
"original_filename": "invoice.pdf",
"stored_filename": "e5f6a7b8.pdf",
"duplicate_of": {
"duplicate_type": "exact",
"original_file_id": 42,
"original_filename": "invoice.pdf",
"message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again."
}
}
```
@@ -1356,7 +1379,7 @@ Test an integration connection without saving. Useful for "Test connection" UI b
{"success": true, "message": "IMAP connection successful"}
```
Supported connection tests: `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported.
Supported connection tests: `DROPBOX`, `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported.
### GET /api/integrations/quota/
@@ -1571,6 +1594,47 @@ Lightweight endpoint returning the total number of queued + in-progress items. D
## Diagnostic
### GET /api/diagnostic/healthz/live
Lightweight liveness probe for Kubernetes. Returns **200 OK** as long as the process is running. This endpoint does **not** check external dependencies and is intentionally cheap.
**Authentication:** None (designed for kubelet probes)
**Response (200 OK):**
```json
{
"status": "ok"
}
```
### GET /api/diagnostic/healthz/ready
Readiness probe for Kubernetes. Verifies that the application can serve traffic by checking database and Redis connectivity.
**Authentication:** None (designed for kubelet probes)
**Response (200 OK) ready to serve traffic:**
```json
{
"status": "ready",
"checks": {
"database": {"status": "ok"},
"redis": {"status": "ok"}
}
}
```
**Response (503 Service Unavailable) database unreachable:**
```json
{
"status": "not_ready",
"checks": {
"database": {"status": "error", "detail": "..."},
"redis": {"status": "ok"}
}
}
```
### GET /api/diagnostic/health
System health endpoint designed for monitoring tools such as Grafana, Uptime Kuma, Prometheus blackbox exporter, or any HTTP-based health checker.
@@ -2127,12 +2191,14 @@ Usage tracking records when each token was last used and from which IP address.
### POST /api/api-tokens/
Create a new API token.
Create a new API token. Optionally specify a lifetime in days via
`expires_in_days` (13650). If omitted the token never expires.
**Request:**
```json
{
"name": "CI Pipeline"
"name": "CI Pipeline",
"expires_in_days": 90
}
```
@@ -2147,7 +2213,8 @@ Create a new API token.
"last_used_at": null,
"last_used_ip": null,
"created_at": "2026-03-08T12:00:00Z",
"revoked_at": null
"revoked_at": null,
"expires_at": "2026-06-06T12:00:00Z"
}
```
@@ -2169,15 +2236,20 @@ List all tokens for the authenticated user. The full token value is never includ
"last_used_at": "2026-03-08T15:30:00Z",
"last_used_ip": "203.0.113.42",
"created_at": "2026-03-08T12:00:00Z",
"revoked_at": null
"revoked_at": null,
"expires_at": "2026-06-06T12:00:00Z"
}
]
```
### DELETE /api/api-tokens/{token_id}
Revoke a token. The token is soft-deleted (kept for audit purposes) and can no
longer be used for authentication.
Revoke or permanently delete a token:
* **Active token** soft-revoked (kept for audit purposes, marked inactive).
Response: `{"detail": "Token revoked"}`
* **Already-revoked token** permanently deleted from the database.
Response: `{"detail": "Token deleted"}`
**Response (200):**
```json
@@ -2186,6 +2258,13 @@ longer be used for authentication.
}
```
### POST /api/api-tokens/{token_id}/reactivate
Reactivate a previously revoked token. Clears `revoked_at` and sets
`is_active` back to `true`.
**Response (200):** The updated `TokenResponse` object.
### Using API Tokens
Include the token in the `Authorization` header of any API request:
@@ -2425,9 +2504,14 @@ List all registered push-notification devices for the current user.
### DELETE /api/mobile/devices/{device_id}
Deactivate a push-notification device. The device will no longer receive push notifications.
Deactivate or permanently delete a push-notification device:
**Response (204 No Content)**
* **Active device** soft-deactivated (record kept, will no longer receive push notifications).
Response: `{"detail": "Device deactivated"}`
* **Already-inactive device** permanently deleted from the database.
Response: `{"detail": "Device deleted"}`
**Response (200)**
### GET /api/mobile/whoami
+285
View File
@@ -0,0 +1,285 @@
# Apple App Store Compliance Audit Report
This document details the findings from a comprehensive audit of the DocuElevate mobile app against Apple's App Store Review Guidelines, Human Interface Guidelines (HIG), and privacy requirements. It covers all areas of compliance, risks for rejection, and recommendations.
> **Last Audited:** March 2026
> **App Version:** 1.0.0
> **Expo SDK:** 54.0.0
> **Bundle ID:** `org.docuelevate.mobile`
---
## Executive Summary
The DocuElevate mobile app is broadly compliant with Apple's App Store requirements. The following issues were identified and resolved as part of this audit:
| Issue | Severity | Status |
|-------|----------|--------|
| Unused `fetch` background mode declared | High | ✅ Fixed |
| Missing privacy manifest for required reason APIs | High | ✅ Fixed |
| No account deletion option (Guideline 5.1.1(v)) | Critical | ✅ Fixed |
| No Privacy Policy / Terms of Service links in-app | High | ✅ Fixed |
| Emoji used as UI icons instead of platform-native icons | Medium | ✅ Fixed |
| Missing app version display | Low | ✅ Fixed |
| Unused `Switch` import in ProfileScreen | Low | ✅ Fixed |
---
## 1. Human Interface Guidelines (HIG)
### 1.1 Navigation & Tab Bar ✅
- The app uses a standard bottom tab bar with three tabs: Upload, Files, and Profile.
- Tab icons use **Ionicons** (an icon set that closely maps to Apple's SF Symbols).
- Active/inactive tab colors follow iOS conventions (`#1e40af` active, `#9ca3af` inactive).
- Header styling uses a solid color background with white text, consistent with iOS navigation bar patterns.
### 1.2 Icons & Visual Assets ✅
- **App icon:** Custom `icon.png` provided at root level; Expo handles generating all required sizes.
- **Splash screen:** Uses branded splash with `contain` resize mode and matching background color.
- **Adaptive icon (Android):** Properly configured with foreground image and background color.
- **Action buttons:** Previously used emoji characters (📷, 🖼️, 📄) which render inconsistently across iOS versions. **Fixed:** Now using Ionicons (`camera-outline`, `images-outline`, `document-outline`).
- **Status indicators:** Previously used emoji (✅, ❌, ⏳, ⚙️). **Fixed:** Now using Ionicons with semantic colors.
### 1.3 Typography & Colors ✅
- Uses system fonts (default React Native text rendering uses San Francisco on iOS).
- Color palette (`#1e40af` primary blue, semantic reds/greens/grays) provides sufficient contrast ratios.
- Text sizes follow iOS recommended minimums (body text ≥ 13pt).
### 1.4 Touch Targets ✅
- All interactive elements have `minHeight: 44` or `minHeight: 48` (meets Apple's 44×44pt minimum).
- Back links, cancel buttons, and retry buttons all meet minimum touch target requirements.
### 1.5 Safe Areas ✅
- The app uses `react-native-safe-area-context` (`SafeAreaProvider`) to respect device notches, Dynamic Island, and home indicator.
### 1.6 Dark Mode ✅
- `userInterfaceStyle: "automatic"` is set in `app.json`, enabling automatic dark mode support.
---
## 2. Privacy & Data Usage
### 2.1 Permission Descriptions ✅
All iOS permission strings (Info.plist keys) are present and provide clear, specific descriptions of why each permission is needed:
| Permission | Key | Description |
|-----------|-----|-------------|
| Camera | `NSCameraUsageDescription` | "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload." |
| Photo Library (Read) | `NSPhotoLibraryUsageDescription` | "DocuElevate accesses your photo library to select documents for upload." |
| Photo Library (Write) | `NSPhotoLibraryAddUsageDescription` | "DocuElevate saves scanned documents to your photo library." |
**Assessment:** All descriptions clearly explain the purpose, which is a requirement for App Review approval.
### 2.2 Push Notifications ✅
- Push notification permission is requested at runtime (not at launch) when the user enters the authenticated area.
- The app works gracefully without push notifications if permission is denied.
- Device tokens are registered via a dedicated backend endpoint.
### 2.3 Background Modes ✅ (Fixed)
- **Previous state:** `UIBackgroundModes` included `["fetch", "remote-notification"]`.
- **Issue:** The app does not implement background fetch (`application:performFetchWithCompletionHandler:`). Apple may reject apps that declare background modes they don't actively use (Guideline 2.5.4).
- **Fix:** Removed `fetch` from `UIBackgroundModes`. Only `remote-notification` remains, which is required for push notification delivery.
### 2.4 Privacy Manifest ✅ (Fixed)
Starting in Spring 2024, Apple requires a privacy manifest (`PrivacyInfo.xcprivacy`) for apps using specific APIs. The following required reason APIs are used by the app's dependencies:
| API Category | Reason Code | Justification |
|-------------|-------------|---------------|
| `NSPrivacyAccessedAPICategoryUserDefaults` | `CA92.1` | Used by `@react-native-async-storage/async-storage` for user preferences |
| `NSPrivacyAccessedAPICategoryFileTimestamp` | `C617.1` | Used by `expo-file-system` to read file metadata |
| `NSPrivacyAccessedAPICategoryDiskSpace` | `E174.1` | Used by Expo runtime for storage space checks |
| `NSPrivacyAccessedAPICategorySystemBootTime` | `35F9.1` | Used by React Native's timing APIs |
The privacy manifest is configured via `expo-build-properties` plugin in `app.json`, which ensures it is included in the generated Xcode project during EAS Build.
### 2.5 Tracking & Analytics ✅
- `NSPrivacyTracking: false` — the app does **not** track users.
- `NSPrivacyCollectedDataTypes: []` — no data types are collected for tracking.
- No analytics SDKs (Firebase Analytics, Amplitude, Mixpanel, etc.) are included.
- No App Tracking Transparency (ATT) prompt is needed.
### 2.6 Encryption Declaration ✅
- `ITSAppUsesNonExemptEncryption: false` — the app uses only standard HTTPS/TLS for network communication, which is exempt from export compliance requirements.
### 2.7 Data Storage Security ✅
- API tokens are stored in the device keychain via `expo-secure-store` (uses iOS Keychain Services).
- No sensitive data is stored in `AsyncStorage` or `UserDefaults`.
- Server URL is stored in secure storage, not in plain text files.
---
## 3. App Store Review Guidelines Compliance
### 3.1 Functionality (Guideline 2.x) ✅
- **2.1 App Completeness:** The app provides a complete, functional experience. All advertised features (camera capture, file upload, document list, push notifications) work as described.
- **2.3 Accurate Metadata:** App name ("DocuElevate"), description, and screenshots should accurately reflect the app's functionality.
- **2.5.4 Background Modes:** Only `remote-notification` is declared, which is actively used. ✅ Fixed.
### 3.2 Content & Intellectual Property (Guideline 3.x) ✅
- No third-party trademarked content is used.
- The app does not display user-generated content publicly (documents are private to each user).
- No copyrighted content is bundled with the app.
### 3.3 Business (Guideline 3.1.x) ✅
- The app does not include in-app purchases, subscriptions, or payment processing.
- No physical goods or services are sold through the app.
- Authentication is handled via self-hosted or enterprise SSO — no Apple Sign-In requirement applies (Apple Sign-In is required only when third-party social login options like Google/Facebook are offered as the primary login method; enterprise SSO to a self-hosted server is exempt).
### 3.4 Safety & Privacy (Guideline 5.x) ✅
- **5.1.1 Data Collection and Storage:** The app collects only what is necessary for its functionality (server URL, auth token, push token).
- **5.1.1(v) Account Deletion:** ✅ Fixed. Users can now initiate account deletion from the Profile screen, which opens the server's account deletion page in the browser.
- **5.1.2 Data Use and Sharing:** No data is shared with third parties or used for advertising.
### 3.5 Privacy Policy ✅ (Fixed)
- **Requirement:** Apple requires all apps to have an accessible privacy policy.
- **Fix:** Privacy Policy and Terms of Service links are now accessible from the Profile screen, opening the server's hosted policy pages.
- **App Store Connect:** The privacy policy URL must also be provided in App Store Connect during submission.
### 3.6 Login & Authentication ✅
- Two login methods are available: SSO (browser-based OAuth) and QR code scanning.
- Both methods provide clear error messages on failure.
- The app correctly handles authentication cancellation.
- Session restoration on app launch is implemented.
- **Demo Account:** For App Review, a demo account may need to be provided in App Store Connect's review notes. Ensure the review team can access a test server.
---
## 4. Technical Compliance
### 4.1 API Usage ✅
- No private APIs are used (all functionality comes from Expo SDK and React Native public APIs).
- No deprecated APIs are used that would trigger rejection.
### 4.2 Network Security ✅
- The app validates server URLs require `http://` or `https://` scheme.
- All API calls use Bearer token authentication over HTTPS.
- App Transport Security (ATS) is not explicitly disabled — default iOS ATS rules apply.
### 4.3 Deep Linking ✅
- Custom URL scheme `docuelevate://` is properly registered.
- Deep link handling for QR login (`docuelevate://qr-login`) and file sharing is implemented correctly.
- `WebBrowser.openAuthSessionAsync` is used for OAuth, which properly handles the authentication session lifecycle.
### 4.4 Document Handling ✅
- `CFBundleDocumentTypes` properly declares supported file types.
- `LSSupportsOpeningDocumentsInPlace: false` ensures iOS copies shared files to the app's accessible Inbox directory, avoiding security-scoped URL issues.
- The `+not-found.tsx` handler correctly intercepts iOS "Open In…" file paths.
- `UploadScreen` uses `expo-file-system` to copy external files to cache before uploading for reliable file access.
### 4.5 Crash Resistance ✅
- All network calls are wrapped in try/catch blocks.
- Error states are displayed to users with actionable recovery options (retry buttons).
- Permission denials are handled gracefully with explanatory messages.
---
## 5. Onboarding & First-Run Experience
### 5.1 Welcome Screen ✅
- Clean, informative welcome screen with app branding and feature highlights.
- Clear "Get Started" call-to-action leading to the login screen.
- No misleading claims or functionality promises.
### 5.2 Login Flow ✅
- Server URL entry with input validation.
- Two clear authentication options (SSO and QR code).
- Error handling with user-friendly alert dialogs.
- Back navigation available from all auth screens.
### 5.3 First-Run Permissions ✅
- Camera permission is requested at the point of use (when tapping Camera button), not at launch.
- Photo library permission is requested at the point of use.
- Push notification permission is requested after authentication, not before.
- All permission requests include clear usage descriptions.
---
## 6. Remaining Recommendations
### 6.1 App Store Connect Preparation
Before submission, ensure the following are configured in App Store Connect:
- [ ] **Privacy Policy URL** — must point to the server's `/privacy` endpoint
- [ ] **App Store description** — accurate description of features
- [ ] **Screenshots** — for iPhone and iPad (since `supportsTablet: true`)
- [ ] **App category** — "Business" or "Productivity"
- [ ] **Age rating** — complete the questionnaire (likely 4+)
- [ ] **Review notes** — provide demo server URL and test credentials for the Apple review team
- [ ] **Privacy Nutrition Labels** — declare data types collected (device ID for push notifications, authentication tokens)
### 6.2 Accessibility Enhancements (Recommended)
While the app includes `accessibilityRole` and `accessibilityLabel` on interactive elements, consider:
- Adding `accessibilityHint` to buttons where the action isn't immediately obvious.
- Testing with VoiceOver to ensure all screens are fully navigable.
- Ensuring all status changes are announced to screen readers.
### 6.3 iPad Support
The app declares `supportsTablet: true`. Ensure:
- UI scales appropriately on iPad screen sizes.
- Split View and Slide Over multitasking work correctly.
- Touch targets remain accessible on larger screens.
### 6.4 Localization (Future Enhancement)
- The app currently uses English-only strings.
- For broader App Store reach, consider localizing the app name, description, and in-app strings.
---
## 7. Compliance Checklist Summary
| Area | Status | Notes |
|------|--------|-------|
| Human Interface Guidelines | ✅ Pass | Ionicons used for platform-consistent iconography |
| App Icons & Visual Assets | ✅ Pass | All required assets provided |
| Device Data Usage | ✅ Pass | Camera, photos, notifications properly handled |
| Privacy Disclosures | ✅ Pass | Info.plist keys and privacy manifest configured |
| Background Modes | ✅ Pass | Only `remote-notification` declared |
| Restricted APIs | ✅ Pass | No private or deprecated APIs used |
| Content Standards | ✅ Pass | No misleading or inappropriate content |
| Functionality | ✅ Pass | Complete, functional app experience |
| Business Model | ✅ Pass | No IAP conflicts |
| Safety & Privacy | ✅ Pass | Account deletion available, privacy policy linked |
| Onboarding | ✅ Pass | Clear, permission-respectful first-run experience |
| Privacy Manifest | ✅ Pass | Required reason APIs declared |
---
## References
- [Apple App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/)
- [Apple Privacy Manifest Requirements](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files)
- [App Store Connect Help](https://developer.apple.com/help/app-store-connect/)
+45 -2
View File
@@ -11,10 +11,15 @@ Configuration is primarily done through environment variables specified in a `.e
| **Variable** | **Description** | **Example** |
|------------------------|----------------------------------------------------------|--------------------------------|
| `DATABASE_URL` | Path/URL to the SQLite database (or other SQL backend). Use the [Database Wizard](/database-wizard) for guided setup. See [Database Configuration](DatabaseConfiguration.md). | `sqlite:///./app/database.db` |
| `DB_POOL_SIZE` | Number of persistent connections in the pool per worker (PostgreSQL/MySQL only; ignored for SQLite). | `10` |
| `DB_MAX_OVERFLOW` | Additional connections beyond `DB_POOL_SIZE` under burst load (PostgreSQL/MySQL only). | `20` |
| `DB_POOL_TIMEOUT` | Seconds to wait for a pool connection before raising `TimeoutError` (PostgreSQL/MySQL only). | `30` |
| `DB_POOL_RECYCLE` | Recycle connections after this many seconds to avoid stale connections (PostgreSQL/MySQL only). | `1800` |
| `REDIS_URL` | URL for Redis, used by Celery for broker & result store. | `redis://redis:6379/0` |
| `WORKDIR` | Working directory for the application. | `/workdir` |
| `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` |
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
| `PUBLIC_BASE_URL` | Full public base URL including scheme (e.g., `https://docuelevate.example.com`). When set, overrides auto-detected URLs used for OAuth redirect URIs. **Required when your reverse proxy does not forward `X-Forwarded-Proto` headers.** | *(not set)* |
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
| `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` |
| `FACTORY_RESET_ON_STARTUP` | Wipe all user data on every startup (demo/testing). | `false` |
@@ -81,6 +86,28 @@ Control how the web UI queues and paces file uploads to avoid overwhelming the b
**Example**: With `UPLOAD_CONCURRENCY=3` and `UPLOAD_QUEUE_DELAY_MS=500`, a directory of 5,000 files is uploaded ≈ 3 at a time with 500 ms pacing the backend processes files at its own rate while the queue drains in the background without triggering API rate limits.
### Per-User Upload Rate Limiting
Server-side rate limiting that prevents any single user from overwhelming the system with bulk uploads. The limiter uses a Redis-backed sliding window and dynamically adjusts limits based on system health.
| **Variable** | **Description** | **Default** |
|--------------------------------|------------------------------------------------------------------------------------------------------------------------------|-------------|
| `UPLOAD_RATE_LIMIT_PER_USER` | Maximum uploads allowed per user within the sliding window. Effective limit may be reduced under load. | `20` |
| `UPLOAD_RATE_LIMIT_WINDOW` | Sliding window size in seconds. | `60` |
**Health-aware dynamic limiting**: The effective per-user limit is automatically reduced when the system is under heavy load:
| **System condition** | **Effective limit** | **Trigger** |
|--------------------------------|---------------------|--------------------------------|
| Normal | 100 % of base | Queue < 50, CPU load normal |
| Moderate load | 50 % of base | Queue 50100 or CPU > 1.5× |
| High load | 25 % of base | Queue 100200 or CPU > 2× |
| Critical load | 10 % of base | Queue > 200 or CPU > 3× |
When a user exceeds the limit, the server returns **HTTP 429 Too Many Requests** with a `Retry-After` header. The browser client (see *Client-Side Upload Throttling* above) automatically pauses and retries.
> **Note**: The limiter fails open — if Redis is unavailable, all uploads are allowed through so that a monitoring outage never blocks document processing.
### File Upload Size Limits
**Security Feature**: Control file upload sizes to prevent resource exhaustion attacks. See [SECURITY_AUDIT.md](../SECURITY_AUDIT.md#5-file-upload-size-limits) for security details.
@@ -1555,14 +1582,30 @@ DocuElevate detects and flags documents that share the same content, even if the
### Exact Duplicate Detection (SHA-256)
When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the new document is stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=<original_id>`) and no further processing is performed.
When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the upload is rejected immediately — no processing task is created, and the temporary file is removed from disk. The `/api/ui-upload` response returns `"status": "duplicate"` together with a `duplicate_of` object that identifies the original file.
If the same file somehow reaches the Celery worker (e.g. via a watch-folder ingest) it is still caught there and stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=<original_id>`) with no further processing.
| Variable | Description | Default |
|---|---|---|
| `ENABLE_DEDUPLICATION` | Hash-based exact duplicate detection on ingest. | `True` |
| `SHOW_DEDUPLICATION_STEP` | Show the "Check for Duplicates" step in the processing timeline UI. | `True` |
An immediate duplicate warning is also included in the `/api/ui-upload` JSON response so the frontend can alert the user before the pipeline completes.
When the upload is an exact duplicate the `/api/ui-upload` response looks like:
```json
{
"status": "duplicate",
"original_filename": "invoice.pdf",
"stored_filename": "abc-123.pdf",
"duplicate_of": {
"duplicate_type": "exact",
"original_file_id": 42,
"original_filename": "invoice.pdf",
"message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again."
}
}
```
### Near-Duplicate Detection (Content Similarity)
+25 -8
View File
@@ -337,18 +337,26 @@ The Helm chart includes a pre-install and pre-upgrade Job hook that runs `alembi
## Connection Pooling
SQLAlchemy manages a connection pool automatically. The defaults are suitable for most deployments. For high-concurrency or Kubernetes deployments you may want to tune:
SQLAlchemy manages a connection pool automatically. DocuElevate selects the pool
strategy based on the database backend:
- **SQLite** — uses `NullPool` (a fresh connection per request, closed immediately).
This avoids the `QueuePool limit reached` `TimeoutError` that can occur under
concurrent load because SQLite does not benefit from persistent connection pooling.
- **PostgreSQL / MySQL** — uses a bounded `QueuePool` whose size is configurable
via environment variables.
```bash
# Optional — these are set via environment variables if you extend app/database.py
# Typical production values:
DB_POOL_SIZE=10 # Number of persistent connections per worker
DB_MAX_OVERFLOW=20 # Additional connections allowed beyond pool_size
DB_POOL_TIMEOUT=30 # Seconds to wait for a connection from the pool
DB_POOL_RECYCLE=1800 # Recycle connections after 30 minutes (avoids stale connections)
# Tune these for PostgreSQL / MySQL (ignored when using SQLite):
DB_POOL_SIZE=10 # Number of persistent connections per worker (default: 10)
DB_MAX_OVERFLOW=20 # Additional connections allowed beyond pool_size (default: 20)
DB_POOL_TIMEOUT=30 # Seconds to wait for a connection from the pool (default: 30)
DB_POOL_RECYCLE=1800 # Recycle connections after 30 minutes (default: 1800)
```
> **Note:** These environment variables are not exposed in the default `app/config.py`. If you need to tune them, extend the database engine creation in `app/database.py`.
All backends also enable `pool_pre_ping`, which sends a lightweight health-check
before each connection is handed out. This detects stale or dropped connections
and transparently reconnects.
For **PgBouncer** (external connection pooling), point `DATABASE_URL` at your PgBouncer instance and use transaction-mode pooling:
@@ -512,4 +520,13 @@ Then retry `alembic upgrade head`.
Either increase `max_connections` in `postgresql.conf` or add PgBouncer in front of PostgreSQL. The default PostgreSQL `max_connections` is `100`; reduce `DB_POOL_SIZE` per worker to stay within this limit.
### "QueuePool limit reached" TimeoutError (SQLite)
If you see `TimeoutError: QueuePool limit of size 5 overflow 10 reached`, your
deployment is still running an older version of DocuElevate that used a bounded
connection pool for SQLite. Upgrade to the latest release — SQLite now uses
`NullPool`, which eliminates this error entirely. If you are already on the
latest version and are still seeing pool exhaustion, ensure you are not
overriding the engine creation manually.
For more help, see the [Troubleshooting Guide](Troubleshooting.md).
+10 -6
View File
@@ -349,16 +349,18 @@ workdir:
## Scaling
DocuElevate is designed for horizontal scaling. Both API and worker pods are stateless and can be scaled independently.
### Docker Compose
Add more worker containers:
Scale workers (task processing) and API pods (request handling) independently:
```yaml
worker:
deploy:
replicas: 3
```bash
docker compose up -d --scale worker=3 --scale api=2
```
> **Note:** The `beat` service (Celery Beat scheduler) must always run as exactly **one** instance. Do not scale it. It publishes periodic tasks to the Redis broker; workers pick them up.
### Kubernetes / Helm
Enable HPA:
@@ -377,13 +379,15 @@ worker:
maxReplicas: 10
```
The Helm chart deploys a separate **beat** pod (always 1 replica, `Recreate` strategy) so that scheduled tasks are never duplicated when workers scale.
---
## Monitoring
- **Docker Compose**: `docker-compose logs -f`, `docker stats`
- **Kubernetes**: `kubectl logs -l app.kubernetes.io/component=api -f`
- **Prometheus / Grafana**: Scrape the `/api/health` endpoint for readiness; add custom metrics as needed.
- **Prometheus / Grafana**: Scrape the `/api/diagnostic/healthz/ready` endpoint for readiness; add custom metrics as needed.
- **Uptime Kuma**: Set `UPTIME_KUMA_URL` to your push URL for heartbeat monitoring.
---
+25 -1
View File
@@ -128,7 +128,31 @@ If you encounter issues with Dropbox integration:
1. **Authentication Errors**: Make sure your App Key and App Secret are correct
2. **Token Expired**: Click "Refresh Token" button on the setup page to obtain a new token
3. **Folder Permissions**: Ensure your app has the correct permissions enabled for file operations
4. **Invalid Redirect URI**: Verify that the redirect URI in your app settings matches the one used in the authentication flow
4. **Invalid Redirect URI**: See section below for the most common cause and fix.
5. **Rate Limiting**: Dropbox API has rate limits; if exceeded, wait and try again
### Fixing "Invalid redirect_uri" Error
This error appears on the Dropbox authorization page when the redirect URI in the OAuth request does not match any URI registered in your Dropbox app console.
**Most common cause**: The application is deployed behind a reverse proxy (Traefik, Nginx, Caddy) that does **not** forward the `X-Forwarded-Proto: https` header to DocuElevate. Without this header, the server cannot determine that it is being accessed over HTTPS and may construct an `http://` redirect URI, while the registered URI in Dropbox is `https://`.
**Fix**:
Option 1 Configure your proxy to forward `X-Forwarded-Proto`:
```nginx
proxy_set_header X-Forwarded-Proto $scheme;
```
Option 2 Set `PUBLIC_BASE_URL` in your environment (recommended for most deployments):
```bash
PUBLIC_BASE_URL=https://docuelevate.example.com
```
When `PUBLIC_BASE_URL` is set, DocuElevate uses it directly for all OAuth redirect URIs instead of trying to infer the scheme from request headers. This is the most reliable option.
After setting `PUBLIC_BASE_URL`, ensure the Dropbox app console redirect URI matches exactly (e.g., `https://docuelevate.example.com/dropbox-callback`). The setup wizard at `/dropbox-setup` will show you the exact URI to register.
For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md).
+13 -5
View File
@@ -373,6 +373,8 @@ worker:
replicaCount: 4
```
> **Beat scheduler:** The Helm chart deploys a dedicated `beat` pod (always exactly 1 replica with `Recreate` strategy) that publishes periodic tasks to the Redis broker. Workers consume these tasks — scaling workers does **not** duplicate scheduled jobs.
### Horizontal Pod Autoscaler
```yaml
@@ -433,24 +435,30 @@ externalRedis:
### Kubernetes Probes
The Helm chart configures liveness and readiness probes on the API pods via `/api/health`. Default settings:
The Helm chart configures **unauthenticated** liveness and readiness probes on the API pods so kubelet can reach them without credentials. Default settings:
```yaml
api:
livenessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/live
port: 8000
initialDelaySeconds: 30
periodSeconds: 30
periodSeconds: 20
readinessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/ready
port: 8000
initialDelaySeconds: 10
initialDelaySeconds: 15
periodSeconds: 10
```
| Endpoint | Auth | Purpose |
|----------|------|---------|
| `/api/diagnostic/healthz/live` | None | Lightweight liveness check — returns 200 if the process is running |
| `/api/diagnostic/healthz/ready` | None | Readiness check — verifies database and Redis connectivity (503 when DB is down) |
| `/api/diagnostic/health` | Required | Full health status for monitoring dashboards (Grafana, Uptime Kuma) |
### Prometheus Scraping
Add annotations to expose metrics (if using a Prometheus-compatible exporter):
+127 -8
View File
@@ -12,9 +12,14 @@ DocuElevate includes a native mobile application for iOS and Android built with
| Auto-generated API token | ✅ | ✅ |
| Camera capture → upload | ✅ | ✅ |
| File picker upload | ✅ | ✅ |
| Multi-image selection from library | ✅ | ✅ |
| Share Sheet / Share Intent | ✅ | ✅ |
| Push notifications | ✅ | ✅ |
| Document list | ✅ | ✅ |
| Document list with search | ✅ | ✅ |
| File detail view with processing logs | ✅ | ✅ |
| Pre-login legal pages (GDPR) | ✅ | ✅ |
| Localization (EN, DE, ES, FR, IT) | ✅ | ✅ |
| Language selection | ✅ | ✅ |
| Dark mode | ✅ | ✅ |
## Getting Started (Development)
@@ -171,8 +176,8 @@ curl -X DELETE -H "Authorization: Bearer <token>" https://your-server/api/mobile
1. Open the **Upload** tab.
2. Tap **Photos**.
3. Select an existing photo from the device's photo library.
4. The image is uploaded and queued for processing.
3. Select one or more photos from the device's photo library (multi-selection is supported).
4. All selected images are uploaded and queued for processing.
### File Picker
@@ -198,6 +203,32 @@ The app registers itself as a share target so any file can be sent directly to D
The URL may arrive as a standard `file://` path **or** under the app's custom `docuelevate://` scheme (e.g. `docuelevate://private/var/mobile/Library/…/file.pdf`). The root layout detects the custom-scheme form and rewrites it to a `file://` URL before forwarding it to the Upload screen through `ShareContext`.
##### Handling "unmatched route" errors from "Open In…"
iOS sometimes delivers the file path under the `docuelevate://` scheme, e.g.:
```
docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf
```
expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. Because no such route exists, it previously threw an **"unmatched route docuelevate://"** error and the upload never completed.
The fix is a catch-all `+not-found.tsx` route (see `mobile/app/+not-found.tsx`). When expo-router cannot match the path, it renders this screen instead. The screen detects that the path is a filesystem path rather than a real in-app route, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext.addPendingFile` deduplicates by URI so the file is only uploaded once.
##### File accessibility and local caching
Shared files may reference paths outside the app's sandbox or use security-scoped URLs that React Native's `fetch` cannot read directly. To guarantee reliable uploads:
- **`LSSupportsOpeningDocumentsInPlace`** is set to `false` in `app.json`, which tells iOS to copy shared files into the app's `Documents/Inbox` directory before handing them to the app.
- **`UploadScreen`** uses `expo-file-system` (`FileSystem.copyAsync`) to copy any `file://` URI that is outside the app's cache/documents directory to a local cache path before uploading. This ensures the file is readable regardless of its origin.
- **MIME type inference**: Both `+not-found.tsx` and the `Linking` handler in `_layout.tsx` infer the MIME type from the file extension (e.g. `.pdf``application/pdf`) so the server receives a correct `Content-Type` instead of `application/octet-stream`.
##### iOS Action / Share Extension (future enhancement)
Apps like DeepL ("Translate in DeepL") and Microsoft Word ("Convert to Word") appear as **Action Extensions** in the iOS share sheet — a system-level feature that requires a separate Xcode target built with Swift or Objective-C. A proper Action Extension runs in its own process and must share authentication credentials with the main app via an iOS **App Group** (shared keychain / shared container).
This level of iOS-native integration is a planned future enhancement. Until it is available, the recommended workflow is the current one: tap **Share → DocuElevate** (the app appears in the "Open With" row of the share sheet via `CFBundleDocumentTypes`).
#### Android implementation
`app.json` declares `ACTION_SEND` and `ACTION_SEND_MULTIPLE` intent filters for `mimeType: "*/*"` in the `android.intentFilters` section. Incoming content URIs are received the same way as on iOS.
@@ -215,6 +246,75 @@ If a file upload fails (e.g. due to network issues or a server error), the faile
The retry re-uses the original file URI so no re-selection is needed.
## Document Search
The **Files** tab includes a search bar at the top that lets users search through their processed documents by filename. Searches are debounced (400ms) to avoid excessive API calls. Clear the search with the ✕ button to return to the full list.
## File Detail View
Tapping any document in the **Files** tab opens a detail view showing:
- **File metadata**: filename, file size, MIME type, upload date, and file hash
- **Processing status**: current status with a colour-coded icon
- **Processing log**: chronological list of processing steps with individual status indicators and timestamps
Pull-to-refresh updates the detail view. This replicates the web interface at `/files/{id}` and `/files/{id}/detail` in a mobile-friendly layout.
## Legal & Compliance
### GDPR & Apple App Store Compliance
Privacy Policy, Terms of Service, and Imprint links are accessible **before login** from both the **Welcome Screen** and the **Login Screen**. This ensures compliance with:
- **GDPR** (General Data Protection Regulation) users must be able to review the privacy policy before providing personal data
- **Apple App Store Review Guidelines** apps must provide accessible privacy information before account creation
Post-login, the same links are available in the **Profile** tab under the "Legal" section.
## Localization (i18n)
The mobile app supports five languages with automatic device-locale detection:
| Language | Code | Status |
|----------|------|--------|
| English | `en` | ✅ Complete |
| German (Deutsch) | `de` | ✅ Complete |
| Spanish (Español) | `es` | ✅ Complete |
| French (Français) | `fr` | ✅ Complete |
| Italian (Italiano) | `it` | ✅ Complete |
### How it works
Language priority (highest to lowest):
1. **Server preference**`preferred_language` returned by `GET /api/mobile/whoami` on login or app resume. Allows a language set on the desktop web interface to propagate to mobile automatically.
2. **AsyncStorage** — the last language explicitly selected on the device, used as an offline fallback when the server is unreachable.
3. **Device locale** — detected via `expo-localization` on first launch.
4. **English** — final fallback when none of the above match a supported locale.
When a user selects a language on mobile the choice is:
- Applied immediately to all screens (via `LocaleContext`)
- Persisted locally to AsyncStorage
- Synced to the server via `POST /api/i18n/language` (fire-and-forget), so the next desktop login reflects the same preference.
> **Note**: If the server's preferred language is not supported by the mobile app (e.g. a locale added to the web frontend but not yet translated for mobile), the mobile app falls back to the next priority in the list above.
### Adding a new language
1. Create a new translation file in `mobile/src/i18n/` (e.g. `pt.json` for Portuguese)
2. Copy the structure from `en.json` and translate all values
3. Import the new file in `mobile/src/i18n/index.ts`
4. Add it to the `translations` object and `getSupportedLanguages()` array
## User Settings
The **Profile** tab includes a **Settings** section where users can:
- **Change language**: Select from the supported languages (English, German, Spanish, French, Italian)
- View server connection details
- Access legal documents (Privacy Policy, Terms of Service, Imprint)
- Sign out or delete their account
## Mobile API Endpoints
The backend exposes a dedicated `/api/mobile/` namespace:
@@ -225,7 +325,8 @@ The backend exposes a dedicated `/api/mobile/` namespace:
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile |
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile (includes `preferred_language`) |
| `POST` | `/api/i18n/language` | Bearer | Sync language preference to server |
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
@@ -269,7 +370,7 @@ Re-registering the same token is safe (idempotent).
### GET /api/mobile/whoami
Returns the current user's profile.
Returns the current user's profile, including the server-stored language preference.
**Response (200):**
```json
@@ -278,10 +379,15 @@ Returns the current user's profile.
"display_name": "John Doe",
"email": "john@example.com",
"avatar_url": "https://www.gravatar.com/avatar/...",
"is_admin": false
"is_admin": false,
"preferred_language": "de"
}
```
`preferred_language` is `null` when no preference has been saved. The mobile
app applies this value on login / app resume, falling back to AsyncStorage and
then the device locale when it is `null` or unsupported.
## Configuration
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
@@ -320,8 +426,20 @@ mobile/
│ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner
│ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
│ ├── FilesScreen.tsx # Processed document list
── ProfileScreen.tsx # User profile + sign out
│ ├── FilesScreen.tsx # Processed document list with search
── FileDetailScreen.tsx # File detail view with processing logs
│ ├── ProfileScreen.tsx # User profile + settings + sign out
│ └── WelcomeScreen.tsx # Pre-login welcome with legal links
├── i18n/ # Localization (i18n)
│ ├── index.ts # i18n module (locale detection, t() function)
│ ├── en.json # English translations
│ ├── de.json # German translations
│ ├── es.json # Spanish translations
│ ├── fr.json # French translations
│ └── it.json # Italian translations
├── utils/
│ ├── mimeTypes.ts # MIME type mapping for file extensions
│ └── normalizeUri.ts # URI normalization for deduplication
└── services/
└── api.ts # DocuElevate REST API client
```
@@ -408,3 +526,4 @@ eas build --platform ios
- [API Documentation](./API.md)
- [Configuration Guide](./ConfigurationGuide.md)
- [Deployment Guide](./DeploymentGuide.md)
- [Apple App Store Compliance Audit](./AppleAppStoreCompliance.md)
+38 -15
View File
@@ -34,7 +34,7 @@ Use this checklist to track readiness before going live.
- [ ] **Redis** — Running and accessible only from internal network
- [ ] **Meilisearch** — Running and accessible only from internal network
- [ ] **Worker replicas** — At least 2 workers configured for redundancy
- [ ] **Monitoring**`/api/health` polled by uptime checker
- [ ] **Monitoring**`/api/diagnostic/health` polled by uptime checker
- [ ] **Backups** — Automated backup of database, workdir, and Meilisearch data
- [ ] **Log retention** — Logs shipped to a persistent store or aggregator
- [ ] **Secrets management** — API keys not committed to source control
@@ -285,22 +285,24 @@ For SSO/OIDC (Authentik, Keycloak, Auth0, etc.) see the [Authentication Setup Gu
### Docker Compose
Use the `deploy.replicas` setting (requires Docker Swarm mode) or simply run multiple workers:
```yaml
worker:
deploy:
replicas: 3
```
Or scale after deployment:
Scale workers independently:
```bash
docker-compose up -d --scale worker=3
docker compose up -d --scale worker=3
```
Each worker processes tasks from the Celery queue independently. Ensure the shared `workdir` volume is accessible from all worker containers.
> **Important:** The `beat` service (Celery Beat scheduler) must always run as exactly **one** instance. It is defined as a dedicated service in `docker-compose.yaml` with a fixed `container_name`. Do not scale it.
### Scaling the API
API pods are fully stateless (sessions use encrypted cookies, not server-side state) and can be scaled behind a load balancer:
```bash
docker compose up -d --scale api=3
```
### Kubernetes (Helm)
```yaml
@@ -339,11 +341,32 @@ celery -A app.celery_worker worker -Q default,celery --concurrency=2
### Health Check Endpoint
DocuElevate exposes `/api/health` for readiness probing. Configure your uptime monitor to poll this endpoint:
DocuElevate exposes three health-related endpoints:
| Endpoint | Auth | Purpose |
|----------|------|---------|
| `GET /api/diagnostic/healthz/live` | None | Lightweight liveness probe — returns 200 if the process is running |
| `GET /api/diagnostic/healthz/ready` | None | Readiness probe — checks database and Redis (503 when DB is down) |
| `GET /api/diagnostic/health` | Required | Full status for monitoring dashboards (Grafana, Uptime Kuma) |
For **Kubernetes probes**, use the unauthenticated endpoints:
```yaml
livenessProbe:
httpGet:
path: /api/diagnostic/healthz/live
port: 8000
readinessProbe:
httpGet:
path: /api/diagnostic/healthz/ready
port: 8000
```
For **uptime monitors** (Uptime Kuma, Grafana, etc.), use the authenticated endpoint:
```bash
curl http://docuelevate.example.com/api/health
# Expected: {"status": "ok", ...}
curl http://docuelevate.example.com/api/diagnostic/health
# Expected: {"status": "healthy", ...}
```
Set `UPTIME_KUMA_URL` to your Uptime Kuma push URL for heartbeat monitoring:
@@ -502,4 +525,4 @@ For a dedicated Kubernetes deployment guide, including architecture diagrams, PV
- **Image Pull Policy**: Use `IfNotPresent` in production with pinned image tags (not `latest`) for reproducible deployments.
- **Liveness & Readiness Probes**: Already configured in the Helm chart via `/api/health`. Verify they are tuned to your startup time.
- **Liveness & Readiness Probes**: Already configured in the Helm chart via unauthenticated endpoints (`/api/diagnostic/healthz/live` and `/api/diagnostic/healthz/ready`). Verify they are tuned to your startup time.
+1 -1
View File
@@ -87,7 +87,7 @@ DocuElevate provides multiple convenient ways to upload documents to the system.
#### Supported File Types
- **Documents**: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx)
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG, HEIC, HEIF
- **Text**: Plain text (.txt), CSV, RTF, HTML, XML, Markdown
- **Maximum file size**: 500MB per file
+11 -3
View File
@@ -433,9 +433,17 @@ function _uploadSingleFile(file, progressBar, statusEl, onTerminal) {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.width = '100%';
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = 'text-xs text-green-600 mt-1';
if (result.status === 'duplicate' && result.duplicate_of) {
// Exact duplicate no processing task was created
progressBar.className = 'file-progress-bar bg-yellow-400 h-2 rounded-full';
statusEl.textContent = `Duplicate already processed (file #${result.duplicate_of.original_file_id})`;
statusEl.className = 'text-xs text-yellow-600 mt-1';
} else {
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = 'text-xs text-green-600 mt-1';
}
_onUploadSuccess();
onTerminal();
resolve({ rateLimited: false, retryAfterSeconds: 0 });
+117 -8
View File
@@ -33,6 +33,20 @@
aria-required="true"
/>
</div>
<div class="sm:w-44">
<label for="token-lifetime" class="sr-only">{{ _("api_tokens.expires_in_days_label") }}</label>
<input
id="token-lifetime"
type="number"
x-model.number="newTokenExpiresDays"
placeholder="{{ _('api_tokens.expires_at_placeholder') }}"
min="1"
max="3650"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
aria-label="{{ _('api_tokens.expires_in_days_label') }}"
/>
</div>
<button
type="submit"
:disabled="creating || !newTokenName.trim()"
@@ -143,6 +157,7 @@
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_created") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_used") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_ip") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_expires") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("common.status") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
</tr>
@@ -162,28 +177,62 @@
<code x-show="token.last_used_ip" class="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono" x-text="token.last_used_ip"></code>
<span x-show="!token.last_used_ip" class="text-gray-400"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
<span x-text="token.expires_at ? formatDate(token.expires_at) : '{{ _('api_tokens.expires_never') }}'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="token.is_active ? '{{ _('api_tokens.status_active') }}' : '{{ _('api_tokens.status_revoked') }}'"
:class="tokenStatusClass(token)"
x-text="tokenStatusLabel(token)"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<!-- Revoke button shown for active tokens -->
<button
x-show="token.is_active"
type="button"
@click="revokeToken(token)"
:disabled="revoking === token.id"
:disabled="acting === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('api_tokens.revoke_prefix') }} ' + token.name"
>
<i :class="revoking === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-ban'" class="mr-1" aria-hidden="true"></i>
{{ _("api_tokens.revoke") }}
</button>
<!-- Reactivate button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="reactivateToken(token)"
:disabled="acting === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-600 hover:text-green-800
dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 transition-colors mr-1"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('api_tokens.reactivate_prefix') }} ' + token.name"
>
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-redo'" class="mr-1" aria-hidden="true"></i>
{{ _("api_tokens.reactivate") }}
</button>
<!-- Delete button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="deleteToken(token)"
:disabled="acting === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('api_tokens.delete_prefix') }} ' + token.name"
>
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
{{ _("api_tokens.delete") }}
</button>
</td>
</tr>
</template>
@@ -209,9 +258,10 @@ function apiTokens() {
tokens: [],
loading: true,
creating: false,
revoking: null,
acting: null,
error: null,
newTokenName: '',
newTokenExpiresDays: null,
newlyCreatedToken: null,
copied: false,
baseUrl: window.location.origin,
@@ -238,13 +288,15 @@ function apiTokens() {
this.error = null;
this.newlyCreatedToken = null;
try {
const body = { name: this.newTokenName.trim() };
if (this.newTokenExpiresDays) body.expires_in_days = parseInt(this.newTokenExpiresDays);
const res = await fetch('/api/api-tokens/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ name: this.newTokenName.trim() }),
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -253,6 +305,7 @@ function apiTokens() {
const data = await res.json();
this.newlyCreatedToken = data.token;
this.newTokenName = '';
this.newTokenExpiresDays = null;
await this.loadTokens();
} catch (e) {
this.error = e.message;
@@ -263,7 +316,7 @@ function apiTokens() {
async revokeToken(token) {
if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return;
this.revoking = token.id;
this.acting = token.id;
this.error = null;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
@@ -278,10 +331,66 @@ function apiTokens() {
} catch (e) {
this.error = e.message;
} finally {
this.revoking = null;
this.acting = null;
}
},
async reactivateToken(token) {
if (!confirm({{ _("api_tokens.reactivate_confirm") | tojson }})) return;
this.acting = token.id;
this.error = null;
try {
const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to reactivate token');
}
await this.loadTokens();
} catch (e) {
this.error = e.message;
} finally {
this.acting = null;
}
},
async deleteToken(token) {
if (!confirm({{ _("api_tokens.delete_confirm") | tojson }})) return;
this.acting = token.id;
this.error = null;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to delete token');
}
await this.loadTokens();
} catch (e) {
this.error = e.message;
} finally {
this.acting = null;
}
},
tokenStatusClass(token) {
if (!token.is_active) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
if (token.expires_at && new Date(token.expires_at) < new Date())
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
},
tokenStatusLabel(token) {
if (!token.is_active) return '{{ _("api_tokens.status_revoked") }}';
if (token.expires_at && new Date(token.expires_at) < new Date())
return '{{ _("api_tokens.status_expired") }}';
return '{{ _("api_tokens.status_active") }}';
},
copyToken() {
if (this.newlyCreatedToken) {
navigator.clipboard.writeText(this.newlyCreatedToken);
+144 -36
View File
@@ -3,7 +3,7 @@
{% block title %}{{ _("devices.page_title") }}{% endblock %}
{% block content %}
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-4xl">
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-6xl">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<header class="mb-8">
@@ -48,55 +48,85 @@
<table class="w-full text-sm" aria-label="{{ _('devices.mobile_tokens_heading') }}">
<thead>
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="token in mobileTokens" :key="token.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2">
<i class="fas fa-mobile-alt text-gray-400" aria-hidden="true"></i>
<span class="font-medium text-gray-900 dark:text-white" x-text="formatDeviceName(token.name)"></span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-4 py-3 whitespace-nowrap">
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span x-text="token.last_used_at ? formatDate(token.last_used_at) : '—'"></span>
<span x-show="token.last_used_ip" class="block text-xs text-gray-400 mt-0.5">
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="token.last_used_ip"></span>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="token.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_revoked') }}'"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<button
x-show="token.is_active"
type="button"
@click="revokeToken(token)"
:disabled="revokingToken === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('devices.revoke_token') }} ' + token.name"
>
<i :class="revokingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-sign-out-alt'" class="mr-1" aria-hidden="true"></i>
{{ _("devices.revoke_token") }}
</button>
<td class="px-4 py-3 whitespace-nowrap text-right">
<div class="inline-flex items-center gap-1">
<!-- Revoke button shown for active tokens -->
<button
x-show="token.is_active"
type="button"
@click="revokeToken(token)"
:disabled="actingToken === token.id"
:title="'{{ _('devices.revoke_token') }}'"
class="inline-flex items-center justify-center w-11 h-11 text-sm text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
:aria-label="'{{ _('devices.revoke_token') }} ' + token.name"
>
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-sign-out-alt'" aria-hidden="true"></i>
</button>
<!-- Reactivate button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="reactivateMobileToken(token)"
:disabled="actingToken === token.id"
:title="'{{ _('devices.reactivate_token') }}'"
class="inline-flex items-center justify-center w-11 h-11 text-sm text-green-600 hover:text-green-800
dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 transition-colors"
:aria-label="'{{ _('devices.reactivate_token') }} ' + token.name"
>
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-redo'" aria-hidden="true"></i>
</button>
<!-- Delete button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="deleteMobileToken(token)"
:disabled="actingToken === token.id"
:title="'{{ _('devices.delete_token') }}'"
class="inline-flex items-center justify-center w-11 h-11 text-sm text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
:aria-label="'{{ _('devices.delete_token') }} ' + token.name"
>
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" aria-hidden="true"></i>
</button>
</div>
</td>
</tr>
</template>
@@ -182,16 +212,31 @@
x-show="device.is_active"
type="button"
@click="deactivateDevice(device)"
:disabled="deactivatingDevice === device.id"
:disabled="actingDevice === device.id"
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('devices.deactivate_device') }} ' + (device.device_name || 'device')"
>
<i :class="deactivatingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
<i :class="actingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-power-off'" class="mr-1" aria-hidden="true"></i>
{{ _("devices.deactivate_device") }}
</button>
<!-- Delete button shown for already-inactive devices -->
<button
x-show="!device.is_active"
type="button"
@click="deleteDevice(device)"
:disabled="actingDevice === device.id"
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('devices.delete_device') }} ' + (device.device_name || 'device')"
>
<i :class="actingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
{{ _("devices.delete_device") }}
</button>
</div>
</template>
</div>
@@ -242,8 +287,8 @@ function devicesPage() {
devices: [],
loadingTokens: true,
loadingDevices: true,
revokingToken: null,
deactivatingDevice: null,
actingToken: null,
actingDevice: null,
tokenError: null,
deviceError: null,
banner: { visible: false, error: false, message: '' },
@@ -286,7 +331,7 @@ function devicesPage() {
async revokeToken(token) {
if (!confirm({{ _("devices.confirm_revoke_token") | tojson }})) return;
this.revokingToken = token.id;
this.actingToken = token.id;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
method: 'DELETE',
@@ -301,19 +346,61 @@ function devicesPage() {
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.revokingToken = null;
this.actingToken = null;
}
},
async reactivateMobileToken(token) {
if (!confirm({{ _("devices.reactivate_token_confirm") | tojson }})) return;
this.actingToken = token.id;
try {
const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to reactivate token');
}
await this.loadMobileTokens();
this._showBanner({{ _("devices.token_reactivated_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingToken = null;
}
},
async deleteMobileToken(token) {
if (!confirm({{ _("devices.delete_token_confirm") | tojson }})) return;
this.actingToken = token.id;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to delete token');
}
await this.loadMobileTokens();
this._showBanner({{ _("devices.token_deleted_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingToken = null;
}
},
async deactivateDevice(device) {
if (!confirm({{ _("devices.confirm_deactivate_device") | tojson }})) return;
this.deactivatingDevice = device.id;
this.actingDevice = device.id;
try {
const res = await fetch(`/api/mobile/devices/${device.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok && res.status !== 204) {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to remove device');
}
@@ -322,7 +409,28 @@ function devicesPage() {
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.deactivatingDevice = null;
this.actingDevice = null;
}
},
async deleteDevice(device) {
if (!confirm({{ _("devices.delete_device_confirm") | tojson }})) return;
this.actingDevice = device.id;
try {
const res = await fetch(`/api/mobile/devices/${device.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to delete device');
}
await this.loadDevices();
this._showBanner({{ _("devices.device_deleted_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingDevice = null;
}
},
+65 -3
View File
@@ -104,7 +104,7 @@
<h3 class="text-xl font-medium mb-4">Step 3: Set OAuth 2 Redirect URI</h3>
<ol class="list-decimal ml-6 space-y-3">
<li>In your app's settings page, go to the "OAuth 2" section</li>
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback</code></li>
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ callback_url }}</code></li>
<li>Click "Add" to save the redirect URI</li>
</ol>
</div>
@@ -116,6 +116,34 @@
</h2>
<div class="space-y-4">
{% if user_mode and global_creds_available %}
<!-- Global credentials mode: no app credentials required from the user -->
<div class="bg-blue-50 border border-blue-200 rounded-md p-4" role="note">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<div>
<p class="text-sm font-medium text-blue-800">Using shared application credentials</p>
<p class="text-sm text-blue-700 mt-1">Your administrator has enabled shared Dropbox app credentials. You can authorize your account without supplying your own App Key and Secret.</p>
</div>
</div>
</div>
{% if folder_path %}
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
<p class="text-xs text-gray-500">Target folder (from integration settings)</p>
<p class="text-sm font-mono text-gray-700">{{ folder_path }}</p>
</div>
{% endif %}
<div>
<button id="start-auth-flow-global" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>
Authorize with Dropbox
</button>
</div>
{% else %}
<div>
<label for="app-key" class="block text-sm font-medium text-gray-700">App Key <span class="text-red-500" aria-hidden="true">*</span></label>
<input type="text" id="app-key" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Dropbox app key" value="{{ app_key_value }}">
@@ -146,6 +174,7 @@
Start Authentication Flow
</button>
</div>
{% endif %}
<!-- Token validation and status (admin mode only) -->
{% if not user_mode %}
@@ -268,6 +297,10 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
<script>
document.addEventListener('DOMContentLoaded', function() {
const userMode = {{ 'true' if user_mode else 'false' }};
const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }};
// Redirect URI for OAuth: prefer server-provided value (respects PUBLIC_BASE_URL),
// fall back to window.location.origin for resilience.
const dropboxCallbackUrl = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
// Store integration_id if provided (for per-user OAuth flow)
const integrationId = "{{ integration_id or '' }}";
@@ -277,6 +310,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Elements
const startAuthFlowBtn = document.getElementById('start-auth-flow');
const startAuthFlowGlobalBtn = document.getElementById('start-auth-flow-global');
const testTokenBtn = document.getElementById('test-token');
const refreshTokenBtn = document.getElementById('refresh-token-btn');
const tokenStatus = document.getElementById('token-status');
@@ -328,11 +362,38 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Global-credentials "Authorize with Dropbox" button (user mode, admin-provided creds)
if (startAuthFlowGlobalBtn) {
startAuthFlowGlobalBtn.addEventListener('click', async function() {
startAuthFlowGlobalBtn.disabled = true;
startAuthFlowGlobalBtn.innerHTML = '<span class="animate-spin inline-block mr-2"></span> Redirecting…';
try {
const resp = await fetch('/api/dropbox/global-authorize-url');
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showModal('error', 'Error', err.detail || 'Could not retrieve authorization URL.');
startAuthFlowGlobalBtn.disabled = false;
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
return;
}
const data = await resp.json();
// Signal to the callback that global credentials should be used for the exchange
sessionStorage.setItem('dropbox_use_global_creds', 'true');
window.location.href = data.authorize_url;
} catch (err) {
showModal('error', 'Network Error', err.message || 'Unknown error');
startAuthFlowGlobalBtn.disabled = false;
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
}
});
}
// Start Authentication Flow button click
startAuthFlowBtn.addEventListener('click', function() {
if (startAuthFlowBtn) {
startAuthFlowBtn.addEventListener('click', function() {
const appKey = document.getElementById('app-key').value.trim();
const appSecret = appSecretInput.value.trim();
const redirectUri = window.location.origin + "/dropbox-callback";
const redirectUri = dropboxCallbackUrl;
if (!appKey) {
showModal('error', 'Validation Error', 'Please enter your App Key');
@@ -362,6 +423,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Redirect the user to the Dropbox login page
window.location.href = authUrl;
});
} // end if (startAuthFlowBtn)
// Test Token button click (admin mode only)
if (testTokenBtn) {
+87 -6
View File
@@ -94,6 +94,7 @@ document.addEventListener('DOMContentLoaded', function() {
const code = "{{ code }}";
// Get credentials from session storage (these take precedence over server-provided values)
const useGlobalCreds = sessionStorage.getItem('dropbox_use_global_creds') === 'true';
const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}";
const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}";
const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads';
@@ -107,20 +108,100 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
const redirectUri = window.location.origin + "/dropbox-callback";
// Use server-provided callback URL (respects PUBLIC_BASE_URL) with fallback to window.location.origin
const redirectUri = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
// Automatically exchange the code for a refresh token
if (code) {
if (!appKey || !appSecret) {
showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
return;
if (useGlobalCreds) {
// Global credentials: the server handles the exchange using the admin app secret
exchangeCodeGlobal(code, redirectUri);
} else {
if (!appKey || !appSecret) {
showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
return;
}
exchangeCode(code, appKey, appSecret, redirectUri);
}
exchangeCode(code, appKey, appSecret, redirectUri);
} else {
showError("No authorization code was found in the URL");
}
function exchangeCodeGlobal(code, redirectUri) {
const formData = new FormData();
formData.append('code', code);
formData.append('redirect_uri', redirectUri);
document.getElementById('processing-message').innerHTML =
'<p>Exchanging authorization code using shared credentials…</p>';
fetch('/api/dropbox/exchange-token-global', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error(err.detail || 'Failed to exchange token');
});
}
return response.json();
})
.then(data => {
if (data.refresh_token) {
const resolvedAppKey = data.app_key || '';
if (integrationId) {
const creds = {
refresh_token: data.refresh_token,
// Store public app_key with the integration (no secret stored browser-side)
app_key: resolvedAppKey,
// Flag so the backend knows to use global app_secret for future operations
use_global_app_secret: true,
};
const body = { credentials: creds };
document.getElementById('processing-message').innerHTML =
'<p>Saving credentials to your integration…</p>';
return fetch(`/api/integrations/${integrationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error'));
});
}
return response.json();
}).then(() => {
// Clean up
sessionStorage.removeItem('dropbox_use_global_creds');
sessionStorage.removeItem('oauth_integration_id');
document.getElementById('processing-message').innerHTML =
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>' +
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations…</p>';
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
});
}
// Global admin flow — the exchange-token-global endpoint is intended for
// per-user integrations, so reaching here without an integrationId is unexpected.
console.warn('dropbox_callback: global creds flow reached without integration_id');
sessionStorage.removeItem('dropbox_use_global_creds');
showSuccess(data.refresh_token, resolvedAppKey, '', folderPath);
setTimeout(() => { window.location.href = '/status'; }, 10000);
} else {
throw new Error('No refresh token was received from the server');
}
})
.catch(error => {
showError(error.message);
});
}
function exchangeCode(code, appKey, appSecret, redirectUri) {
const formData = new FormData();
formData.append('client_id', appKey);
@@ -1392,7 +1392,7 @@ function integrationsDashboard() {
body: JSON.stringify({
integration_type: intg.integration_type,
config: intg.config,
credentials: creds,
credentials: creds.credentials,
}),
});
const data = await resp.json();
+10 -15
View File
@@ -32,7 +32,13 @@
id="qr-container"
aria-label="{{ _('qr_login.description') }}"
>
<canvas id="qr-canvas" width="256" height="256"></canvas>
<img
:src="qrCodeSvg"
width="256"
height="256"
alt="{{ _('qr_login.description') }}"
id="qr-image"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
{{ _("qr_login.description") }}
@@ -110,8 +116,7 @@
</section>
</div>
<!-- QR Code library (lightweight, no external deps) -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<!-- QR code is rendered server-side; no external QR library needed -->
<script>
function qrLoginPage() {
@@ -120,6 +125,7 @@ function qrLoginPage() {
challengeId: null,
challengeToken: '',
qrPayload: '',
qrCodeSvg: '',
expiresAt: null,
countdown: 0,
deviceName: '',
@@ -157,24 +163,13 @@ function qrLoginPage() {
this.challengeId = data.challenge_id;
this.challengeToken = data.challenge_token;
this.qrPayload = data.qr_payload;
this.qrCodeSvg = data.qr_code_svg;
this.expiresAt = new Date(data.expires_at);
this._ttlSeconds = data.ttl_seconds || 120;
this._receivedAt = Date.now();
this.status = 'pending';
this.deviceName = '';
// Render QR code
this.$nextTick(() => {
const canvas = document.getElementById('qr-canvas');
if (canvas && typeof QRCode !== 'undefined') {
QRCode.toCanvas(canvas, this.qrPayload, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
}
});
// Start polling and countdown
this._startPolling();
this._startCountdown();
+14 -1
View File
@@ -32,6 +32,14 @@
error: '',
async submit() {
this.error = '';
if (this.username.length < 3 || this.username.length > 64) {
this.error = 'Username must be between 3 and 64 characters.';
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(this.username)) {
this.error = 'Username may only contain letters, numbers, hyphens, and underscores. Dots and other special characters are not allowed.';
return;
}
if (this.password !== this.password_confirm) {
this.error = 'Passwords do not match.';
return;
@@ -58,7 +66,12 @@
}
} else {
const data = await resp.json();
this.error = data.detail || 'Registration failed. Please try again.';
const detail = data.detail;
if (Array.isArray(detail)) {
this.error = detail.map(e => e.msg || String(e)).join(' ') || 'Registration failed. Please try again.';
} else {
this.error = detail || 'Registration failed. Please try again.';
}
}
} catch(e) {
this.error = 'Network error. Please try again.';
+22
View File
@@ -316,6 +316,7 @@
"admin_users.total_no_users": "No users",
"admin_users.total_one_user": "1 user",
"api_tokens.col_created": "Created",
"api_tokens.col_expires": "Expires",
"api_tokens.col_last_ip": "Last IP",
"api_tokens.col_last_used": "Last Used",
"api_tokens.col_name": "Name",
@@ -327,6 +328,14 @@
"api_tokens.create_heading": "Create New Token",
"api_tokens.create_token": "Create Token",
"api_tokens.creating": "Creating…",
"api_tokens.delete": "Delete",
"api_tokens.delete_confirm": "Permanently delete this revoked token? This cannot be undone.",
"api_tokens.delete_prefix": "Permanently delete token",
"api_tokens.expires_at_label": "Expires (optional)",
"api_tokens.expires_at_placeholder": "e.g. 30, 90, 365 days",
"api_tokens.expires_in_days_label": "Token lifetime (days)",
"api_tokens.expires_never": "Never",
"api_tokens.expires_on": "Expires",
"api_tokens.heading": "API Tokens",
"api_tokens.intro": "Create personal API tokens to interact with the DocuElevate API programmatically. Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload or retrieve documents.",
"api_tokens.loading_tokens": "Loading tokens…",
@@ -334,9 +343,13 @@
"api_tokens.no_tokens_heading": "No API tokens yet",
"api_tokens.no_tokens_help": "Create your first token above to get started.",
"api_tokens.page_title": "API Tokens DocuElevate",
"api_tokens.reactivate": "Reactivate",
"api_tokens.reactivate_confirm": "Reactivate this token? It will be usable again immediately.",
"api_tokens.reactivate_prefix": "Reactivate token",
"api_tokens.revoke": "Revoke",
"api_tokens.revoke_prefix": "Revoke token",
"api_tokens.status_active": "Active",
"api_tokens.status_expired": "Expired",
"api_tokens.status_revoked": "Revoked",
"api_tokens.table_aria": "API Tokens",
"api_tokens.token_created": "Token created successfully!",
@@ -620,6 +633,11 @@
"devices.confirm_deactivate_device": "Remove this device? It will stop receiving push notifications.",
"devices.confirm_revoke_token": "Revoke access for this device? It will need to log in again.",
"devices.deactivate_device": "Remove",
"devices.delete_device": "Delete",
"devices.delete_device_confirm": "Permanently delete this inactive device? This cannot be undone.",
"devices.delete_token": "Delete",
"devices.delete_token_confirm": "Permanently delete this revoked token? This cannot be undone.",
"devices.device_deleted_success": "Device permanently deleted.",
"devices.device_removed_success": "Device removed successfully.",
"devices.heading": "Mobile Devices",
"devices.intro": "Manage your mobile app connections and registered devices. You can revoke access for individual devices here.",
@@ -632,12 +650,16 @@
"devices.no_mobile_tokens_help": "Log in via the mobile app or scan a QR code to create a mobile token.",
"devices.page_title": "Devices DocuElevate",
"devices.qr_login_cta": "Connect a new device via QR code",
"devices.reactivate_token": "Reactivate",
"devices.reactivate_token_confirm": "Reactivate this token? The device will be able to use it again immediately.",
"devices.registered_devices_description": "Devices registered for push notifications from the DocuElevate mobile app.",
"devices.registered_devices_heading": "Registered Devices",
"devices.revoke_token": "Revoke",
"devices.status_active": "Active",
"devices.status_inactive": "Inactive",
"devices.status_revoked": "Revoked",
"devices.token_deleted_success": "Token permanently deleted.",
"devices.token_reactivated_success": "Token reactivated successfully.",
"devices.token_revoked_success": "Device token revoked successfully.",
"duplicates.file_id_label": "File ID",
"duplicates.file_id_placeholder": "e.g. 42",
@@ -0,0 +1,83 @@
{{- /*
Celery Beat scheduler — publishes periodic tasks to the broker.
Exactly ONE replica must run; never scale this deployment.
*/ -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "docuelevate.fullname" . }}-beat
namespace: {{ .Release.Namespace }}
labels:
{{- include "docuelevate.labels" . | nindent 4 }}
app.kubernetes.io/component: beat
spec:
replicas: 1
strategy:
type: Recreate # Prevent two Beat instances from running simultaneously
selector:
matchLabels:
{{- include "docuelevate.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: beat
template:
metadata:
labels:
{{- include "docuelevate.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: beat
{{- with .Values.beat.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "docuelevate.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.beat.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: beat
image: {{ include "docuelevate.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- celery
- -A
- app.celery_worker
- beat
- --loglevel=info
envFrom:
- configMapRef:
name: {{ include "docuelevate.fullname" . }}-config
- secretRef:
name: {{ include "docuelevate.fullname" . }}-secret
{{- with .Values.beat.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.beat.resources | nindent 12 }}
volumeMounts:
- name: workdir
mountPath: /workdir
volumes:
- name: workdir
{{- if .Values.workdir.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.workdir.persistence.existingClaim | default (printf "%s-workdir" (include "docuelevate.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.beat.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.beat.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.beat.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -42,7 +42,6 @@ spec:
- -A
- app.celery_worker
- worker
- -B
- --loglevel=info
- -Q
- document_processor,default,celery
+33 -4
View File
@@ -121,10 +121,10 @@ api:
type: ClusterIP
port: 8000
# Liveness / readiness probes
# Liveness / readiness probes (unauthenticated endpoints for kubelet)
livenessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/live
port: 8000
initialDelaySeconds: 30
periodSeconds: 20
@@ -132,7 +132,7 @@ api:
readinessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/ready
port: 8000
initialDelaySeconds: 15
periodSeconds: 10
@@ -191,7 +191,36 @@ worker:
drop: ["ALL"]
# ---------------------------------------------------------------------------
# Shared workdir volume (api + worker mount the same PVC)
# Celery Beat scheduler (singleton — always exactly 1 replica)
# Beat publishes periodic tasks; workers consume them from the broker.
# ---------------------------------------------------------------------------
beat:
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
# ---------------------------------------------------------------------------
# Shared workdir volume (api + worker + beat mount the same PVC)
# ---------------------------------------------------------------------------
workdir:
persistence:
@@ -0,0 +1,43 @@
"""Add expires_at column to api_tokens table.
Allows API tokens to be issued with an optional lifetime. If ``expires_at``
is set, the token is automatically rejected after that timestamp.
Revision ID: 038_add_api_token_expires_at
Revises: 037_add_user_sessions_and_qr_challenges
Create Date: 2026-03-18
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "038_add_api_token_expires_at"
down_revision: Union[str, None] = "037_add_user_sessions_and_qr_challenges"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Add expires_at column to api_tokens (idempotent)."""
conn = op.get_bind()
inspector = sa.inspect(conn)
if "api_tokens" not in inspector.get_table_names():
return
existing_columns = {col["name"] for col in inspector.get_columns("api_tokens")}
if "expires_at" not in existing_columns:
op.add_column(
"api_tokens",
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
"""Remove expires_at column from api_tokens."""
conn = op.get_bind()
inspector = sa.inspect(conn)
if "api_tokens" not in inspector.get_table_names():
return
existing_columns = {col["name"] for col in inspector.get_columns("api_tokens")}
if "expires_at" in existing_columns:
op.drop_column("api_tokens", "expires_at")
@@ -1,7 +1,7 @@
"""Add classification_rules table for custom document classification rules.
Revision ID: 038_add_classification_rules
Revises: 037_add_user_sessions_and_qr_challenges
Revision ID: 039_add_classification_rules
Revises: 038_add_api_token_expires_at
Create Date: 2026-03-17
"""
@@ -10,8 +10,8 @@ from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "038_add_classification_rules"
down_revision: Union[str, None] = "037_add_user_sessions_and_qr_challenges"
revision: str = "039_add_classification_rules"
down_revision: Union[str, None] = "038_add_api_token_expires_at"
depends_on: Union[str, None] = None
+14
View File
@@ -163,6 +163,16 @@ The app registers itself as a share target so any file can be sent directly to D
The root layout (`app/_layout.tsx`) listens for incoming URLs via `Linking.addEventListener` (warm start) and `Linking.getInitialURL()` (cold start). If the URL uses the `docuelevate://` scheme it is automatically rewritten to `file://` before being forwarded. Incoming files are stored in `ShareContext` and automatically uploaded by `UploadScreen`.
#### Handling "unmatched route" errors from "Open In…"
iOS sometimes delivers the file path under the `docuelevate://` scheme:
```
docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf
```
expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. The catch-all `app/+not-found.tsx` intercepts this, detects the filesystem-path pattern, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext` deduplicates by URI to prevent double uploads.
**Supported iOS file types:** PDF, images (JPEG / PNG / GIF / BMP / TIFF / WebP), plain text, Word (`.docx`, `.doc`), Excel (`.xlsx`, `.xls`), PowerPoint (`.pptx`, `.ppt`), and any other file (`public.data`).
To use the share sheet:
@@ -174,6 +184,10 @@ To use the share sheet:
> **Note:** `CFBundleDocumentTypes` with `LSHandlerRank: Alternate` means DocuElevate appears in the share sheet as an option but does **not** become the default app for any file type.
#### iOS Action Extension (future enhancement)
Apps like DeepL ("Translate in DeepL") appear as **Action Extensions** in the iOS share sheet, which requires a separate Xcode target and native Swift code. This is planned as a future enhancement. The current `CFBundleDocumentTypes` approach places DocuElevate in the "Open With" row of the share sheet.
### Android how it works
`app.json` declares `intentFilters` for `ACTION_SEND` and `ACTION_SEND_MULTIPLE` with `mimeType: "*/*"`. When a user shares a file from another app and selects DocuElevate, Android delivers the content URI through the share intent, which is captured via `Linking.getInitialURL()` and processed the same way as on iOS.
+25 -3
View File
@@ -22,9 +22,9 @@
"NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.",
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
"UIBackgroundModes": ["fetch", "remote-notification"],
"UIBackgroundModes": ["remote-notification"],
"ITSAppUsesNonExemptEncryption": false,
"LSSupportsOpeningDocumentsInPlace": true,
"LSSupportsOpeningDocumentsInPlace": false,
"CFBundleDocumentTypes": [
{
"CFBundleTypeName": "All Documents",
@@ -86,7 +86,29 @@
"expo-build-properties",
{
"ios": {
"buildReactNativeFromSource": true
"buildReactNativeFromSource": true,
"privacyManifests": {
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace",
"NSPrivacyAccessedAPITypeReasons": ["E174.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
}
],
"NSPrivacyCollectedDataTypes": [],
"NSPrivacyTracking": false
}
}
}
],
+22 -8
View File
@@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { usePushNotifications } from "../../src/hooks/usePushNotifications";
import { useAuth } from "../../src/context/AuthContext";
import { useLocale, t } from "../../src/i18n";
export default function TabLayout() {
const { isAuthenticated } = useAuth();
usePushNotifications(isAuthenticated);
// Subscribe to language changes so tab labels re-render when the language
// is switched. The `lang` variable is intentionally unused its only
// purpose is to make this component a consumer of LocaleContext.
useLocale();
return (
<Tabs
@@ -37,8 +42,8 @@ export default function TabLayout() {
<Tabs.Screen
name="index"
options={{
title: "Upload",
tabBarLabel: "Upload",
title: t("tabs.upload"),
tabBarLabel: t("tabs.upload"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="cloud-upload-outline" size={size} color={color} />
),
@@ -48,23 +53,32 @@ export default function TabLayout() {
<Tabs.Screen
name="files"
options={{
title: "Files",
tabBarLabel: "Files",
title: t("tabs.files"),
tabBarLabel: t("tabs.files"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} />
),
headerTitle: "My Documents",
headerTitle: t("files.title"),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarLabel: "Profile",
title: t("tabs.profile"),
tabBarLabel: t("tabs.profile"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-circle-outline" size={size} color={color} />
),
headerTitle: "Profile",
headerTitle: t("tabs.profile"),
}}
/>
{/* File detail screen hidden from tab bar, accessed via navigation */}
<Tabs.Screen
name="file-detail"
options={{
href: null,
title: t("file_detail.title"),
headerTitle: t("file_detail.title"),
}}
/>
</Tabs>
+4
View File
@@ -0,0 +1,4 @@
/**
* File detail route displays processing status and logs for a single file.
*/
export { default } from "../../src/screens/FileDetailScreen";
+154
View File
@@ -0,0 +1,154 @@
/**
* Catch-all "not found" route for expo-router.
*
* This screen intercepts two different situations:
*
* 1. **iOS "Open In…" / share sheet** iOS delivers files to the app via a
* `docuelevate://<path>` URL. expo-router strips the custom scheme and
* tries to match the raw filesystem path (e.g.
* `/private/var/mobile/Library/…/file.pdf`) as an in-app route. Because
* no such route exists, expo-router previously threw "unmatched route
* docuelevate://…" and the upload never happened.
*
* This screen detects the filesystem-path pattern, adds the file directly
* to `ShareContext`, and redirects to the Upload tab. `UploadScreen`
* picks up the pending file and begins uploading automatically.
*
* The `Linking` listener in `_layout.tsx` may also fire for the same URL;
* `ShareContext.addPendingFile` deduplicates by URI so the file is only
* uploaded once.
*
* 2. **Any other unmatched in-app route** redirect silently to the root so
* the user isn't left on a blank error page.
*/
import { usePathname, useRouter } from "expo-router";
import React, { useEffect } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import { useShare } from "../src/context/ShareContext";
import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* First path-segment names that identify iOS/Android sandbox filesystem paths.
* These can never be expo-router route-group names, so their presence is a
* strong positive signal that the URL is a shared file rather than a route.
*
* iOS: /private/var/mobile/ "private"
* /var/mobile/ "var" (symlink to /private/var/mobile)
* /tmp/ "tmp"
* Android: /data/user/0/ "data"
* /storage/emulated/0/ "storage"
*/
const FS_PATH_ROOTS = ["private", "var", "tmp", "data", "storage"];
/**
* Route-group / special-file prefixes that identify genuine in-app routes
* rather than filesystem path segments.
*
* Keep this list in sync with the top-level entries in the `app/`
* directory. Add an entry here if you add a new top-level route group
* that does **not** use the parentheses convention.
*/
const IN_APP_ROUTE_PREFIXES = [
"(auth)", // app/(auth)/
"(tabs)", // app/(tabs)/
"_", // expo-router special files (_layout, _sitemap, …)
"+", // expo-router special files (+not-found, …)
"--", // Expo Go development proxy prefix
];
/**
* Return `true` when `pathname` looks like a filesystem path delivered by iOS
* "Open In…" (e.g. `/private/var/mobile/Library/…/file.pdf`) rather than a
* legitimate in-app route.
*
* Detection strategy:
* 1. **Positive check** if the first path segment matches a known device
* filesystem root (see `FS_PATH_ROOTS`), it is definitely a file path.
* 2. **Fallback negative check** if the path does not start with any known
* in-app route prefix (see `IN_APP_ROUTE_PREFIXES`), treat it as a file
* path. This is a heuristic but safe because expo-router route groups
* always use parentheses (e.g. `(auth)`, `(tabs)`).
*/
function looksLikeFilePath(pathname: string): boolean {
const stripped = pathname.replace(/^\/+/, "");
if (stripped.length === 0) return false;
// Positive signal: path starts with a known device filesystem root segment.
const firstSegment = stripped.split("/")[0];
if (FS_PATH_ROOTS.includes(firstSegment)) return true;
// Fallback: paths that start with a known in-app route prefix are routes.
return !IN_APP_ROUTE_PREFIXES.some((prefix) => stripped.startsWith(prefix));
}
/**
* Extract a display filename from a filesystem path.
* Handles URL-encoded characters and strips query strings.
*/
function filenameFromPath(pathname: string): string {
try {
const decoded = decodeURIComponent(pathname);
const segments = decoded.split("/").filter(Boolean);
const last = segments[segments.length - 1] ?? "shared_file";
return last.split("?")[0] || "shared_file";
} catch {
return "shared_file";
}
}
// ---------------------------------------------------------------------------
// Screen component
// ---------------------------------------------------------------------------
export default function NotFoundScreen() {
const pathname = usePathname();
const router = useRouter();
const { addPendingFile } = useShare();
// Guard: track which pathname has been handled so the effect does not
// re-fire when `router` or `addPendingFile` change identity mid-navigation.
const handledRef = React.useRef<string | null>(null);
useEffect(() => {
if (handledRef.current === pathname) return; // already handled
handledRef.current = pathname;
if (looksLikeFilePath(pathname)) {
// Filesystem path from iOS "Open In…" add the file to ShareContext
// and redirect to the Upload tab. UploadScreen will pick up the
// pending file and begin uploading automatically.
//
// The pathname from expo-router is the raw filesystem path
// (e.g. "/private/var/mobile/Library/…/file.pdf"). Reconstruct a
// file:// URI so the upload logic can read the file.
const fileUri = `file://${pathname}`;
const filename = filenameFromPath(pathname);
addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
router.replace("/(tabs)/");
} else {
// Truly unknown in-app route fall back to the root redirect.
router.replace("/");
}
}, [pathname, router, addPendingFile]);
// Show a brief spinner while the redirect is in flight.
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
},
});
+54 -10
View File
@@ -9,6 +9,11 @@
* sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming
* file:// / content:// URL is captured and forwarded to UploadScreen via
* ShareContext.
*
* The companion `+not-found.tsx` handles the case where expo-router receives
* a `docuelevate://` URL with a filesystem path (from iOS "Open In…") and
* cannot match it to a route. It adds the file directly to ShareContext and
* redirects to the Upload tab so the file is uploaded transparently.
*/
import * as Linking from "expo-linking";
@@ -18,6 +23,8 @@ import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider, useAuth } from "../src/context/AuthContext";
import { ShareProvider, useShare } from "../src/context/ShareContext";
import { LocaleProvider, useLocale, isLanguageSupported } from "../src/i18n";
import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
// ---------------------------------------------------------------------------
// Helpers
@@ -26,6 +33,13 @@ import { ShareProvider, useShare } from "../src/context/ShareContext";
/** The custom URL scheme registered in app.json. */
const APP_SCHEME_PREFIX = "docuelevate://";
/**
* Known deep-link path prefixes that should NOT be treated as shared files.
* These are in-app deep-link routes handled by their respective screens
* (e.g. QR login, OAuth callback).
*/
const DEEP_LINK_PATHS = ["qr-login", "callback"];
/** Extract a display filename from a file:// or content:// URI. */
function filenameFromUri(uri: string): string {
try {
@@ -43,12 +57,18 @@ function filenameFromUri(uri: string): string {
* URLs to ShareContext. Extracted as a module-level factory so the handler
* itself is created once and can be easily unit-tested without a React context.
*
* On iOS the Share Sheet / "Open In" action may deliver the file path under
* On iOS the Share Sheet / "Open In" action may deliver the file path under
* the app's custom URL scheme (`docuelevate://…/file.pdf`) instead of a plain
* `file://` URL. When that happens we rewrite the URL to `file:///…` so the
* upload logic can read the file normally.
*
* Note: expo-router also receives the same URL and will attempt to match it as
* an in-app route. When no route matches it renders `+not-found.tsx`, which
* adds the file to ShareContext directly and redirects to the Upload tab.
* Both this handler and `+not-found.tsx` call `addPendingFile`;
* `ShareContext` deduplicates by URI so the file is only uploaded once.
*/
function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) {
function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mimeType?: string }) => void) {
return ({ url }: { url: string }) => {
let fileUri = url;
@@ -57,13 +77,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) =
// (expo-router groups always start with "(").
if (url.startsWith(APP_SCHEME_PREFIX)) {
const path = url.slice(APP_SCHEME_PREFIX.length);
if (path.length > 0 && !path.startsWith("(")) {
// Skip known in-app deep-link paths (e.g. qr-login, callback).
// These are handled by their respective screens, not the share flow.
const pathBase = path.split("?")[0].replace(/^\/+/, "");
if (DEEP_LINK_PATHS.includes(pathBase) || path.startsWith("(")) {
return;
}
if (path.length > 0) {
fileUri = "file:///" + path.replace(/^\/+/, "");
}
}
if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return;
addPendingFile({ uri: fileUri, filename: filenameFromUri(fileUri) });
const filename = filenameFromUri(fileUri);
addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
};
}
@@ -72,11 +101,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) =
// ---------------------------------------------------------------------------
function AuthGuard() {
const { isLoading, isAuthenticated } = useAuth();
const { isLoading, isAuthenticated, user } = useAuth();
const { addPendingFile } = useShare();
const { setLang } = useLocale();
const segments = useSegments();
const router = useRouter();
// Apply the server-side language preference whenever the user profile is
// loaded (on login or app resume). This syncs the language set on the
// desktop/web client to the mobile app. If the server language is not
// supported by the mobile app, we leave the current language unchanged.
useEffect(() => {
if (user?.preferred_language && isLanguageSupported(user.preferred_language)) {
void setLang(user.preferred_language);
}
}, [user?.preferred_language, setLang]);
// Listen for files shared from other apps (iOS Share Sheet / Android Intent).
// Both cold-start (app was not running) and warm-start (app in background)
// cases are handled.
@@ -121,6 +161,8 @@ function AuthGuard() {
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
{/* +not-found handles unmatched routes such as iOS "Open In…" file paths */}
<Stack.Screen name="+not-found" />
</Stack>
);
}
@@ -132,11 +174,13 @@ function AuthGuard() {
export default function RootLayout() {
return (
<SafeAreaProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
<LocaleProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
</LocaleProvider>
</SafeAreaProvider>
);
}
+1
View File
@@ -0,0 +1 @@
module.exports = require("eslint-config-expo/flat");
+122 -437
View File
@@ -27,6 +27,7 @@
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
@@ -44,7 +45,7 @@
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~19.1.0",
"eslint": "^8.57.0",
"eslint": "^9.0.0",
"eslint-config-expo": "~10.0.0",
"typescript": "^5.3.0"
},
@@ -1633,37 +1634,40 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
"integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.12.4",
"ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^9.6.0",
"globals": "^13.19.0",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"minimatch": "^3.1.2",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/js": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
"integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@@ -2313,22 +2317,6 @@
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"deprecated": "Use @eslint/config-array instead",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
"engines": {
"node": ">=10.10.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -2343,14 +2331,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/object-schema": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"deprecated": "Use @eslint/object-schema instead",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
@@ -2667,44 +2647,6 @@
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nolyfill/is-core-module": {
"version": "1.0.39",
"resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
@@ -5766,19 +5708,6 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -6068,60 +5997,63 @@
}
},
"node_modules/eslint": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4",
"@eslint/js": "8.57.1",
"@humanwhocodes/config-array": "^0.13.0",
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.2",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "9.39.4",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
"ajv": "^6.12.4",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^7.2.2",
"eslint-visitor-keys": "^3.4.3",
"espree": "^9.6.1",
"esquery": "^1.4.2",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^6.0.1",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"globals": "^13.19.0",
"graphemer": "^1.4.0",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"is-path-inside": "^3.0.3",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"levn": "^0.4.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"minimatch": "^3.1.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3",
"strip-ansi": "^6.0.1",
"text-table": "^0.2.0"
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-config-expo": {
@@ -6260,191 +6192,6 @@
"eslint": ">=8.10"
}
},
"node_modules/eslint-plugin-expo/node_modules/@eslint/eslintrc": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
"integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-expo/node_modules/@eslint/js": {
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
"integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/eslint-plugin-expo/node_modules/eslint": {
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.2",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "9.39.4",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-plugin-expo/node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-expo/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-expo/node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-expo/node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/eslint-plugin-expo/node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/eslint-plugin-expo/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-import": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
@@ -6586,9 +6333,9 @@
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -6596,7 +6343,7 @@
"estraverse": "^5.2.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -6615,19 +6362,45 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1"
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -7078,6 +6851,19 @@
"react-native": "*"
}
},
"node_modules/expo-localization": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
"license": "MIT",
"dependencies": {
"rtl-detect": "^1.0.2"
},
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-manifests": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz",
@@ -7335,16 +7121,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -7428,16 +7204,16 @@
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^3.0.4"
"flat-cache": "^4.0.0"
},
"engines": {
"node": "^10.12.0 || >=12.0.0"
"node": ">=16.0.0"
}
},
"node_modules/fill-range": {
@@ -7512,24 +7288,23 @@
}
},
"node_modules/flat-cache": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.3",
"rimraf": "^3.0.2"
"keyv": "^4.5.4"
},
"engines": {
"node": "^10.12.0 || >=12.0.0"
"node": ">=16"
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -7827,16 +7602,13 @@
}
},
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=8"
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -7877,13 +7649,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true,
"license": "MIT"
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -8518,16 +8283,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
@@ -10891,27 +10646,6 @@
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -11484,17 +11218,6 @@
"node": ">=4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -11532,29 +11255,11 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
"node_modules/rtl-detect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
@@ -12483,13 +12188,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true,
"license": "MIT"
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -12651,19 +12349,6 @@
"node": ">=4"
}
},
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+3 -2
View File
@@ -8,7 +8,7 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint src --ext .ts,.tsx",
"lint": "eslint src",
"type-check": "tsc --noEmit",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
@@ -36,6 +36,7 @@
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
@@ -53,7 +54,7 @@
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~19.1.0",
"eslint": "^8.57.0",
"eslint": "^9.0.0",
"eslint-config-expo": "~10.0.0",
"typescript": "^5.3.0"
},
+8 -1
View File
@@ -9,6 +9,7 @@
*/
import React, { createContext, useCallback, useContext, useState } from "react";
import { normalizeFileUri } from "../utils/normalizeUri";
export interface SharedFile {
uri: string;
@@ -32,7 +33,13 @@ export function ShareProvider({ children }: { children: React.ReactNode }) {
const [pendingFiles, setPendingFiles] = useState<SharedFile[]>([]);
const addPendingFile = useCallback((file: SharedFile) => {
setPendingFiles((prev) => [...prev, file]);
setPendingFiles((prev) => {
// Deduplicate by normalised URI so the same file is not uploaded twice
// when both the Linking handler (_layout.tsx) and +not-found.tsx fire.
const norm = normalizeFileUri(file.uri);
if (prev.some((f) => normalizeFileUri(f.uri) === norm)) return prev;
return [...prev, file];
});
}, []);
const clearPendingFiles = useCallback(() => {
+116
View File
@@ -0,0 +1,116 @@
{
"common": {
"retry": "Erneut versuchen",
"cancel": "Abbrechen",
"back": "Zurück",
"error": "Fehler",
"loading": "Laden…",
"search": "Suchen",
"clear_search": "Suche löschen"
},
"welcome": {
"tagline": "Intelligente Dokumentenverarbeitung",
"description": "Dokumente einlesen, OCR durchführen, Metadaten mit KI extrahieren und Dateien in Ihren Cloud-Speicher leiten alles in einer nahtlosen Pipeline.",
"get_started": "Loslegen",
"hint": "Verbinden Sie sich mit Ihrem selbst gehosteten oder Cloud-DocuElevate-Server.",
"feature_ocr_title": "OCR & Texterkennung",
"feature_ocr_desc": "Gescannte PDFs und Bilder automatisch in durchsuchbaren Text umwandeln.",
"feature_ai_title": "KI-Metadatenextraktion",
"feature_ai_desc": "KI klassifiziert Dokumente und extrahiert Schlüsselfelder wie Datum, Beträge und Betreff.",
"feature_cloud_title": "Multi-Cloud-Speicher",
"feature_cloud_desc": "Verarbeitete Dateien an Dropbox, Google Drive, OneDrive, S3, Nextcloud und mehr weiterleiten."
},
"login": {
"server_url": "Server-URL",
"server_url_placeholder": "https://ihr-docuelevate-server.com",
"sign_in_sso": "Mit SSO anmelden",
"scan_qr": "📱 QR-Code scannen zum Anmelden",
"hint": "Melden Sie sich per SSO an oder scannen Sie einen QR-Code aus der Web-App.",
"back": "← Zurück",
"or": "oder",
"server_url_required": "Server-URL erforderlich",
"server_url_required_msg": "Bitte geben Sie die URL Ihres DocuElevate-Servers ein.",
"invalid_url": "Ungültige URL",
"invalid_url_msg": "Die Server-URL muss mit http:// oder https:// beginnen",
"sign_in_failed": "Anmeldung fehlgeschlagen",
"qr_login_failed": "QR-Anmeldung fehlgeschlagen"
},
"upload": {
"camera": "Kamera",
"photos": "Fotos",
"files": "Dateien",
"camera_access_title": "Kamerazugriff erforderlich",
"camera_access_msg": "Bitte erlauben Sie den Kamerazugriff in den Einstellungen, um Dokumente aufzunehmen.",
"photo_access_title": "Fotobibliothek-Zugriff erforderlich",
"photo_access_msg": "Bitte erlauben Sie den Zugriff auf die Fotobibliothek in den Einstellungen.",
"file_picker_error": "Dateiauswahl-Fehler",
"file_picker_error_msg": "Dateiauswahl konnte nicht geöffnet werden",
"empty_title": "Tippen Sie auf Kamera, Fotos oder Dateien, um ein Dokument hochzuladen.",
"empty_hint": "Sie können auch Dateien aus anderen Apps direkt an DocuElevate senden.",
"sign_in_required": "Bitte melden Sie sich an, um Dokumente hochzuladen.",
"status_queued": "In der Warteschlange…",
"status_processing": "Wird verarbeitet…",
"status_completed": "Verarbeitet",
"status_failed": "Verarbeitung fehlgeschlagen",
"status_duplicate": "Duplikat bereits verarbeitet",
"tap_retry": "Zum Wiederholen tippen",
"retry_title": "Upload wiederholen",
"retry_msg": "Möchten Sie den Upload von \"{filename}\" wiederholen?",
"capture_label": "Dokument mit Kamera aufnehmen",
"photo_label": "Foto aus der Bibliothek auswählen",
"file_label": "Datei vom Gerät auswählen"
},
"files": {
"title": "Meine Dokumente",
"search_placeholder": "Dokumente durchsuchen…",
"empty_title": "Noch keine Dokumente.",
"empty_hint": "Laden Sie ein Dokument über den Upload-Tab hoch.",
"search_empty": "Keine Dokumente gefunden.",
"search_empty_hint": "Versuchen Sie einen anderen Suchbegriff.",
"view_details": "Details für {filename} anzeigen"
},
"file_detail": {
"title": "Dateidetails",
"back": "Zurück zu Dateien",
"file_size": "Dateigröße",
"mime_type": "MIME-Typ",
"uploaded": "Hochgeladen",
"file_hash": "Datei-Hash",
"last_step": "Letzter Schritt",
"total_steps": "Gesamtschritte",
"processing_log": "Verarbeitungsprotokoll",
"no_logs": "Noch keine Verarbeitungsprotokolle.",
"file_not_found": "Datei nicht gefunden"
},
"profile": {
"title": "Profil",
"not_signed_in": "Nicht angemeldet",
"connection": "Verbindung",
"server": "Server",
"user_id": "Benutzer-ID",
"legal": "Rechtliches",
"privacy_policy": "Datenschutzrichtlinie",
"terms_of_service": "Nutzungsbedingungen",
"imprint": "Impressum",
"sign_out": "Abmelden",
"sign_out_title": "Abmelden",
"sign_out_msg": "Möchten Sie sich wirklich abmelden?",
"delete_account": "Konto löschen",
"delete_account_title": "Konto löschen",
"delete_account_msg": "Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"could_not_open": "Konnte {page} nicht öffnen. Bitte versuchen Sie es erneut.",
"admin": "Admin",
"settings": "Einstellungen",
"language": "Sprache"
},
"legal": {
"privacy_policy": "Datenschutz",
"terms": "AGB",
"imprint": "Impressum"
},
"tabs": {
"upload": "Hochladen",
"files": "Dateien",
"profile": "Profil"
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"common": {
"retry": "Retry",
"cancel": "Cancel",
"back": "Back",
"error": "Error",
"loading": "Loading…",
"search": "Search",
"clear_search": "Clear search"
},
"welcome": {
"tagline": "Intelligent Document Processing",
"description": "Ingest documents, run OCR, extract metadata with AI, and route files to your cloud storage — all in one seamless pipeline.",
"get_started": "Get Started",
"hint": "Connect to your self-hosted or cloud DocuElevate server.",
"feature_ocr_title": "OCR & Text Extraction",
"feature_ocr_desc": "Convert scanned PDFs and images into fully searchable text automatically.",
"feature_ai_title": "AI Metadata Extraction",
"feature_ai_desc": "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
"feature_cloud_title": "Multi-Cloud Storage",
"feature_cloud_desc": "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more."
},
"login": {
"server_url": "Server URL",
"server_url_placeholder": "https://your-docuelevate-server.com",
"sign_in_sso": "Sign in with SSO",
"scan_qr": "📱 Scan QR Code to Login",
"hint": "Sign in via SSO or scan a QR code from the web app.",
"back": "← Back",
"or": "or",
"server_url_required": "Server URL required",
"server_url_required_msg": "Please enter the URL of your DocuElevate server.",
"invalid_url": "Invalid URL",
"invalid_url_msg": "The server URL must start with http:// or https://",
"sign_in_failed": "Sign-in failed",
"qr_login_failed": "QR Login Failed"
},
"upload": {
"camera": "Camera",
"photos": "Photos",
"files": "Files",
"camera_access_title": "Camera access required",
"camera_access_msg": "Please grant camera access in Settings to capture documents.",
"photo_access_title": "Photo library access required",
"photo_access_msg": "Please grant photo library access in Settings to select images.",
"file_picker_error": "File picker error",
"file_picker_error_msg": "Could not open file picker",
"empty_title": "Tap Camera, Photos, or Files to upload a document.",
"empty_hint": "You can also share files from other apps directly to DocuElevate.",
"sign_in_required": "Please sign in to upload documents.",
"status_queued": "Queued for processing…",
"status_processing": "Processing…",
"status_completed": "Processed",
"status_failed": "Processing failed",
"status_duplicate": "Duplicate already processed",
"tap_retry": "Tap to retry",
"retry_title": "Retry Upload",
"retry_msg": "Do you want to retry uploading \"{filename}\"?",
"capture_label": "Capture document with camera",
"photo_label": "Select photo from library",
"file_label": "Pick file from device"
},
"files": {
"title": "My Documents",
"search_placeholder": "Search documents…",
"empty_title": "No documents yet.",
"empty_hint": "Upload a document from the Upload tab to get started.",
"search_empty": "No documents match your search.",
"search_empty_hint": "Try a different search term.",
"view_details": "View details for {filename}"
},
"file_detail": {
"title": "File Details",
"back": "Back to Files",
"file_size": "File Size",
"mime_type": "MIME Type",
"uploaded": "Uploaded",
"file_hash": "File Hash",
"last_step": "Last Step",
"total_steps": "Total Steps",
"processing_log": "Processing Log",
"no_logs": "No processing logs yet.",
"file_not_found": "File not found"
},
"profile": {
"title": "Profile",
"not_signed_in": "Not signed in",
"connection": "Connection",
"server": "Server",
"user_id": "User ID",
"legal": "Legal",
"privacy_policy": "Privacy Policy",
"terms_of_service": "Terms of Service",
"imprint": "Imprint",
"sign_out": "Sign out",
"sign_out_title": "Sign out",
"sign_out_msg": "Are you sure you want to sign out?",
"delete_account": "Delete Account",
"delete_account_title": "Delete Account",
"delete_account_msg": "This will permanently delete your account and all associated data. This action cannot be undone.",
"could_not_open": "Could not open the {page}. Please try again.",
"admin": "Admin",
"settings": "Settings",
"language": "Language"
},
"legal": {
"privacy_policy": "Privacy Policy",
"terms": "Terms",
"imprint": "Imprint"
},
"tabs": {
"upload": "Upload",
"files": "Files",
"profile": "Profile"
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"common": {
"retry": "Reintentar",
"cancel": "Cancelar",
"back": "Atrás",
"error": "Error",
"loading": "Cargando…",
"search": "Buscar",
"clear_search": "Borrar búsqueda"
},
"welcome": {
"tagline": "Procesamiento Inteligente de Documentos",
"description": "Ingiere documentos, ejecuta OCR, extrae metadatos con IA y envía archivos a tu almacenamiento en la nube — todo en una sola línea de trabajo.",
"get_started": "Comenzar",
"hint": "Conéctate a tu servidor DocuElevate autoalojado o en la nube.",
"feature_ocr_title": "OCR y Extracción de Texto",
"feature_ocr_desc": "Convierte PDFs e imágenes escaneadas en texto completamente buscable automáticamente.",
"feature_ai_title": "Extracción de Metadatos con IA",
"feature_ai_desc": "La IA clasifica documentos y extrae campos clave como fechas, montos y asuntos.",
"feature_cloud_title": "Almacenamiento Multi-Nube",
"feature_cloud_desc": "Envía archivos procesados a Dropbox, Google Drive, OneDrive, S3, Nextcloud y más."
},
"login": {
"server_url": "URL del Servidor",
"server_url_placeholder": "https://tu-servidor-docuelevate.com",
"sign_in_sso": "Iniciar sesión con SSO",
"scan_qr": "📱 Escanear código QR para iniciar sesión",
"hint": "Inicia sesión mediante SSO o escanea un código QR desde la app web.",
"back": "← Atrás",
"or": "o",
"server_url_required": "URL del servidor requerida",
"server_url_required_msg": "Por favor ingresa la URL de tu servidor DocuElevate.",
"invalid_url": "URL inválida",
"invalid_url_msg": "La URL del servidor debe comenzar con http:// o https://",
"sign_in_failed": "Error al iniciar sesión",
"qr_login_failed": "Error en inicio de sesión QR"
},
"upload": {
"camera": "Cámara",
"photos": "Fotos",
"files": "Archivos",
"camera_access_title": "Acceso a la cámara requerido",
"camera_access_msg": "Permite el acceso a la cámara en Ajustes para capturar documentos.",
"photo_access_title": "Acceso a la biblioteca de fotos requerido",
"photo_access_msg": "Permite el acceso a la biblioteca de fotos en Ajustes para seleccionar imágenes.",
"file_picker_error": "Error del selector de archivos",
"file_picker_error_msg": "No se pudo abrir el selector de archivos",
"empty_title": "Toca Cámara, Fotos o Archivos para subir un documento.",
"empty_hint": "También puedes compartir archivos desde otras apps directamente a DocuElevate.",
"sign_in_required": "Inicia sesión para subir documentos.",
"status_queued": "En cola para procesamiento…",
"status_processing": "Procesando…",
"status_completed": "Procesado",
"status_failed": "Procesamiento fallido",
"status_duplicate": "Duplicado ya procesado",
"tap_retry": "Toca para reintentar",
"retry_title": "Reintentar Subida",
"retry_msg": "¿Deseas reintentar la subida de \"{filename}\"?",
"capture_label": "Capturar documento con la cámara",
"photo_label": "Seleccionar foto de la biblioteca",
"file_label": "Seleccionar archivo del dispositivo"
},
"files": {
"title": "Mis Documentos",
"search_placeholder": "Buscar documentos…",
"empty_title": "Aún no hay documentos.",
"empty_hint": "Sube un documento desde la pestaña Subir para comenzar.",
"search_empty": "Ningún documento coincide con tu búsqueda.",
"search_empty_hint": "Intenta con otro término de búsqueda.",
"view_details": "Ver detalles de {filename}"
},
"file_detail": {
"title": "Detalles del Archivo",
"back": "Volver a Archivos",
"file_size": "Tamaño",
"mime_type": "Tipo MIME",
"uploaded": "Subido",
"file_hash": "Hash del Archivo",
"last_step": "Último Paso",
"total_steps": "Pasos Totales",
"processing_log": "Registro de Procesamiento",
"no_logs": "Aún no hay registros de procesamiento.",
"file_not_found": "Archivo no encontrado"
},
"profile": {
"title": "Perfil",
"not_signed_in": "No has iniciado sesión",
"connection": "Conexión",
"server": "Servidor",
"user_id": "ID de Usuario",
"legal": "Legal",
"privacy_policy": "Política de Privacidad",
"terms_of_service": "Términos de Servicio",
"imprint": "Aviso Legal",
"sign_out": "Cerrar sesión",
"sign_out_title": "Cerrar sesión",
"sign_out_msg": "¿Estás seguro de que deseas cerrar sesión?",
"delete_account": "Eliminar Cuenta",
"delete_account_title": "Eliminar Cuenta",
"delete_account_msg": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.",
"could_not_open": "No se pudo abrir {page}. Inténtalo de nuevo.",
"admin": "Admin",
"settings": "Configuración",
"language": "Idioma"
},
"legal": {
"privacy_policy": "Privacidad",
"terms": "Términos",
"imprint": "Aviso Legal"
},
"tabs": {
"upload": "Subir",
"files": "Archivos",
"profile": "Perfil"
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"common": {
"retry": "Réessayer",
"cancel": "Annuler",
"back": "Retour",
"error": "Erreur",
"loading": "Chargement…",
"search": "Rechercher",
"clear_search": "Effacer la recherche"
},
"welcome": {
"tagline": "Traitement Intelligent de Documents",
"description": "Ingérez des documents, lancez l'OCR, extrayez les métadonnées avec l'IA et transférez les fichiers vers votre stockage cloud — le tout dans un flux unique.",
"get_started": "Commencer",
"hint": "Connectez-vous à votre serveur DocuElevate auto-hébergé ou cloud.",
"feature_ocr_title": "OCR et Extraction de Texte",
"feature_ocr_desc": "Convertissez automatiquement les PDF scannés et les images en texte entièrement consultable.",
"feature_ai_title": "Extraction de Métadonnées par IA",
"feature_ai_desc": "L'IA classe les documents et extrait les champs clés comme les dates, montants et sujets.",
"feature_cloud_title": "Stockage Multi-Cloud",
"feature_cloud_desc": "Transférez les fichiers traités vers Dropbox, Google Drive, OneDrive, S3, Nextcloud et plus."
},
"login": {
"server_url": "URL du Serveur",
"server_url_placeholder": "https://votre-serveur-docuelevate.com",
"sign_in_sso": "Se connecter avec SSO",
"scan_qr": "📱 Scanner le code QR pour se connecter",
"hint": "Connectez-vous via SSO ou scannez un code QR depuis l'application web.",
"back": "← Retour",
"or": "ou",
"server_url_required": "URL du serveur requise",
"server_url_required_msg": "Veuillez entrer l'URL de votre serveur DocuElevate.",
"invalid_url": "URL invalide",
"invalid_url_msg": "L'URL du serveur doit commencer par http:// ou https://",
"sign_in_failed": "Échec de la connexion",
"qr_login_failed": "Échec de la connexion QR"
},
"upload": {
"camera": "Appareil photo",
"photos": "Photos",
"files": "Fichiers",
"camera_access_title": "Accès à l'appareil photo requis",
"camera_access_msg": "Veuillez autoriser l'accès à l'appareil photo dans les Réglages pour capturer des documents.",
"photo_access_title": "Accès à la photothèque requis",
"photo_access_msg": "Veuillez autoriser l'accès à la photothèque dans les Réglages pour sélectionner des images.",
"file_picker_error": "Erreur du sélecteur de fichiers",
"file_picker_error_msg": "Impossible d'ouvrir le sélecteur de fichiers",
"empty_title": "Appuyez sur Appareil photo, Photos ou Fichiers pour télécharger un document.",
"empty_hint": "Vous pouvez aussi partager des fichiers depuis d'autres applications vers DocuElevate.",
"sign_in_required": "Veuillez vous connecter pour télécharger des documents.",
"status_queued": "En file d'attente…",
"status_processing": "En cours de traitement…",
"status_completed": "Traité",
"status_failed": "Échec du traitement",
"status_duplicate": "Doublon déjà traité",
"tap_retry": "Appuyez pour réessayer",
"retry_title": "Réessayer le téléchargement",
"retry_msg": "Voulez-vous réessayer le téléchargement de \"{filename}\" ?",
"capture_label": "Capturer un document avec l'appareil photo",
"photo_label": "Sélectionner une photo de la bibliothèque",
"file_label": "Choisir un fichier depuis l'appareil"
},
"files": {
"title": "Mes Documents",
"search_placeholder": "Rechercher des documents…",
"empty_title": "Pas encore de documents.",
"empty_hint": "Téléchargez un document depuis l'onglet Télécharger pour commencer.",
"search_empty": "Aucun document ne correspond à votre recherche.",
"search_empty_hint": "Essayez un autre terme de recherche.",
"view_details": "Voir les détails de {filename}"
},
"file_detail": {
"title": "Détails du Fichier",
"back": "Retour aux Fichiers",
"file_size": "Taille",
"mime_type": "Type MIME",
"uploaded": "Téléchargé",
"file_hash": "Hash du Fichier",
"last_step": "Dernière Étape",
"total_steps": "Étapes Totales",
"processing_log": "Journal de Traitement",
"no_logs": "Pas encore de journaux de traitement.",
"file_not_found": "Fichier non trouvé"
},
"profile": {
"title": "Profil",
"not_signed_in": "Non connecté",
"connection": "Connexion",
"server": "Serveur",
"user_id": "ID Utilisateur",
"legal": "Mentions Légales",
"privacy_policy": "Politique de Confidentialité",
"terms_of_service": "Conditions d'Utilisation",
"imprint": "Mentions Légales",
"sign_out": "Se déconnecter",
"sign_out_title": "Se déconnecter",
"sign_out_msg": "Êtes-vous sûr de vouloir vous déconnecter ?",
"delete_account": "Supprimer le Compte",
"delete_account_title": "Supprimer le Compte",
"delete_account_msg": "Cela supprimera définitivement votre compte et toutes les données associées. Cette action est irréversible.",
"could_not_open": "Impossible d'ouvrir {page}. Veuillez réessayer.",
"admin": "Admin",
"settings": "Paramètres",
"language": "Langue"
},
"legal": {
"privacy_policy": "Confidentialité",
"terms": "Conditions",
"imprint": "Mentions Légales"
},
"tabs": {
"upload": "Télécharger",
"files": "Fichiers",
"profile": "Profil"
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Lightweight i18n module for the DocuElevate mobile app.
*
* Uses the device locale (via expo-localization) to select the best matching
* translation file. Falls back to English for missing keys or unsupported
* locales.
*
* Supported languages: English, German, Spanish, French, Italian.
*
* ## React integration
*
* Wrap the app root in `<LocaleProvider>` and call `useLocale()` in any
* component that renders translated strings. `useLocale()` returns the
* active language code and a `setLang` setter that:
* 1. Updates the in-memory `currentLanguage` variable (so `t()` picks it up)
* 2. Triggers a React re-render of every consumer
* 3. Persists the choice to AsyncStorage (survives app restarts)
*
* Language priority on startup:
* server preference (from /api/mobile/whoami) > AsyncStorage > device locale > "en"
*/
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getLocales } from "expo-localization";
import React from "react";
import de from "./de.json";
import en from "./en.json";
import es from "./es.json";
import fr from "./fr.json";
import it from "./it.json";
// ---------------------------------------------------------------------------
// Translation catalog
// ---------------------------------------------------------------------------
type TranslationMap = Record<string, Record<string, string>>;
const translations: Record<string, TranslationMap> = { en, de, es, fr, it };
// ---------------------------------------------------------------------------
// Locale detection
// ---------------------------------------------------------------------------
const LANG_STORAGE_KEY = "@docuelevate:language";
/** Resolve the best-matching language code from the device locale list. */
function detectLanguage(): string {
try {
const locales = getLocales();
if (locales.length > 0) {
// Try exact match first (e.g. "de"), then fall back to language prefix
const code = locales[0].languageCode?.toLowerCase();
if (code && translations[code]) return code;
}
} catch {
// getLocales() can throw on some platforms default to English
}
return "en";
}
let currentLanguage: string = detectLanguage();
// ---------------------------------------------------------------------------
// Plain-function public API (framework-agnostic)
// ---------------------------------------------------------------------------
/**
* Translate a dot-separated key, e.g. `t("upload.camera")`.
*
* Supports simple placeholder interpolation:
* `t("upload.retry_msg", { filename: "doc.pdf" })`
* replaces `{filename}` in the translated string.
*
* Falls back to the English value, then to the raw key if no translation
* exists.
*/
export function t(key: string, params?: Record<string, string>): string {
const [section, ...rest] = key.split(".");
const subKey = rest.join(".");
let value =
translations[currentLanguage]?.[section]?.[subKey] ??
translations.en?.[section]?.[subKey] ??
key;
if (params) {
for (const [k, v] of Object.entries(params)) {
value = value.replaceAll(`{${k}}`, v);
}
}
return value;
}
/** Return the current language code (e.g. "en", "de"). */
export function getLanguage(): string {
return currentLanguage;
}
/**
* Update the active language in memory.
* Prefer `useLocale().setLang` inside React components it also persists
* the choice and triggers re-renders.
*/
export function setLanguage(lang: string): void {
if (translations[lang]) {
currentLanguage = lang;
}
}
/** Return true if the given language code is supported by the mobile app. */
export function isLanguageSupported(lang: string): boolean {
return Object.prototype.hasOwnProperty.call(translations, lang);
}
/** Return the list of supported language codes. */
export function getSupportedLanguages(): { code: string; label: string }[] {
return [
{ code: "en", label: "English" },
{ code: "de", label: "Deutsch" },
{ code: "es", label: "Español" },
{ code: "fr", label: "Français" },
{ code: "it", label: "Italiano" },
];
}
// ---------------------------------------------------------------------------
// React integration context + provider + hook
// ---------------------------------------------------------------------------
interface LocaleContextValue {
/** The active language code, e.g. "en" or "de". */
lang: string;
/**
* Switch to a new language. Persists the choice to AsyncStorage and
* triggers a re-render of every `useLocale()` consumer.
*/
setLang: (code: string) => Promise<void>;
}
const LocaleContext = React.createContext<LocaleContextValue>({
lang: currentLanguage,
// Default setter used outside of a provider updates in-memory only.
setLang: async (code: string) => {
setLanguage(code);
},
});
/**
* Wrap the app root in `LocaleProvider` to enable reactive language switching.
*
* On mount it reads the persisted language from AsyncStorage so the user's
* choice survives app restarts. The server-preferred language is applied
* externally (see `AuthGuard` in `app/_layout.tsx`) after the profile is
* fetched from `/api/mobile/whoami`.
*/
export function LocaleProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [lang, setLangState] = React.useState(currentLanguage);
// Restore the persisted language preference once on app start.
React.useEffect(() => {
AsyncStorage.getItem(LANG_STORAGE_KEY)
.then((saved) => {
if (saved && isLanguageSupported(saved)) {
setLanguage(saved);
setLangState(saved);
}
})
.catch(() => {
// Ignore read errors fall back to device-detected language.
});
}, []);
const setLang = React.useCallback(async (code: string): Promise<void> => {
if (!isLanguageSupported(code)) return;
setLanguage(code);
setLangState(code);
try {
await AsyncStorage.setItem(LANG_STORAGE_KEY, code);
} catch {
// Ignore write errors the in-memory change is still applied.
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // setLangState is a React state setter its identity is guaranteed stable
const value = React.useMemo(() => ({ lang, setLang }), [lang, setLang]);
return React.createElement(LocaleContext.Provider, { value }, children);
}
/**
* Hook that subscribes to language changes.
*
* Any component calling `useLocale()` re-renders automatically when the
* language changes. Call `t()` freely inside the component body the
* re-render will pick up the new translations.
*
* ```tsx
* function MyScreen() {
* const { lang, setLang } = useLocale(); // subscribes to changes
* return <Text>{t("common.loading")}</Text>;
* }
* ```
*/
export function useLocale(): LocaleContextValue {
return React.useContext(LocaleContext);
}
+116
View File
@@ -0,0 +1,116 @@
{
"common": {
"retry": "Riprova",
"cancel": "Annulla",
"back": "Indietro",
"error": "Errore",
"loading": "Caricamento…",
"search": "Cerca",
"clear_search": "Cancella ricerca"
},
"welcome": {
"tagline": "Elaborazione Intelligente dei Documenti",
"description": "Acquisisci documenti, esegui l'OCR, estrai metadati con l'IA e invia i file al tuo cloud storage — tutto in un unico flusso.",
"get_started": "Inizia",
"hint": "Collegati al tuo server DocuElevate self-hosted o cloud.",
"feature_ocr_title": "OCR ed Estrazione Testo",
"feature_ocr_desc": "Converti automaticamente PDF e immagini scansionate in testo completamente ricercabile.",
"feature_ai_title": "Estrazione Metadati con IA",
"feature_ai_desc": "L'IA classifica i documenti ed estrae campi chiave come date, importi e oggetti.",
"feature_cloud_title": "Archiviazione Multi-Cloud",
"feature_cloud_desc": "Invia i file elaborati a Dropbox, Google Drive, OneDrive, S3, Nextcloud e altro."
},
"login": {
"server_url": "URL del Server",
"server_url_placeholder": "https://il-tuo-server-docuelevate.com",
"sign_in_sso": "Accedi con SSO",
"scan_qr": "📱 Scansiona il codice QR per accedere",
"hint": "Accedi tramite SSO o scansiona un codice QR dall'app web.",
"back": "← Indietro",
"or": "o",
"server_url_required": "URL del server richiesto",
"server_url_required_msg": "Inserisci l'URL del tuo server DocuElevate.",
"invalid_url": "URL non valido",
"invalid_url_msg": "L'URL del server deve iniziare con http:// o https://",
"sign_in_failed": "Accesso fallito",
"qr_login_failed": "Accesso QR fallito"
},
"upload": {
"camera": "Fotocamera",
"photos": "Foto",
"files": "File",
"camera_access_title": "Accesso alla fotocamera richiesto",
"camera_access_msg": "Consenti l'accesso alla fotocamera nelle Impostazioni per acquisire documenti.",
"photo_access_title": "Accesso alla libreria foto richiesto",
"photo_access_msg": "Consenti l'accesso alla libreria foto nelle Impostazioni per selezionare immagini.",
"file_picker_error": "Errore nel selettore file",
"file_picker_error_msg": "Impossibile aprire il selettore file",
"empty_title": "Tocca Fotocamera, Foto o File per caricare un documento.",
"empty_hint": "Puoi anche condividere file da altre app direttamente su DocuElevate.",
"sign_in_required": "Accedi per caricare documenti.",
"status_queued": "In coda per l'elaborazione…",
"status_processing": "Elaborazione in corso…",
"status_completed": "Elaborato",
"status_failed": "Elaborazione fallita",
"status_duplicate": "Duplicato già elaborato",
"tap_retry": "Tocca per riprovare",
"retry_title": "Riprova Caricamento",
"retry_msg": "Vuoi riprovare a caricare \"{filename}\"?",
"capture_label": "Acquisisci documento con la fotocamera",
"photo_label": "Seleziona foto dalla libreria",
"file_label": "Seleziona file dal dispositivo"
},
"files": {
"title": "I Miei Documenti",
"search_placeholder": "Cerca documenti…",
"empty_title": "Nessun documento ancora.",
"empty_hint": "Carica un documento dalla scheda Carica per iniziare.",
"search_empty": "Nessun documento corrisponde alla tua ricerca.",
"search_empty_hint": "Prova con un altro termine di ricerca.",
"view_details": "Visualizza dettagli per {filename}"
},
"file_detail": {
"title": "Dettagli File",
"back": "Torna ai File",
"file_size": "Dimensione",
"mime_type": "Tipo MIME",
"uploaded": "Caricato",
"file_hash": "Hash del File",
"last_step": "Ultimo Passaggio",
"total_steps": "Passaggi Totali",
"processing_log": "Registro di Elaborazione",
"no_logs": "Nessun registro di elaborazione ancora.",
"file_not_found": "File non trovato"
},
"profile": {
"title": "Profilo",
"not_signed_in": "Non connesso",
"connection": "Connessione",
"server": "Server",
"user_id": "ID Utente",
"legal": "Legale",
"privacy_policy": "Informativa sulla Privacy",
"terms_of_service": "Termini di Servizio",
"imprint": "Note Legali",
"sign_out": "Esci",
"sign_out_title": "Esci",
"sign_out_msg": "Sei sicuro di voler uscire?",
"delete_account": "Elimina Account",
"delete_account_title": "Elimina Account",
"delete_account_msg": "Questo eliminerà permanentemente il tuo account e tutti i dati associati. Questa azione non può essere annullata.",
"could_not_open": "Impossibile aprire {page}. Riprova.",
"admin": "Admin",
"settings": "Impostazioni",
"language": "Lingua"
},
"legal": {
"privacy_policy": "Privacy",
"terms": "Termini",
"imprint": "Note Legali"
},
"tabs": {
"upload": "Carica",
"files": "File",
"profile": "Profilo"
}
}
+332
View File
@@ -0,0 +1,332 @@
/**
* FileDetailScreen shows detailed status and processing logs for a single file.
*
* Replicates the web /files/:id and /files/:id/detail views in a
* mobile-friendly layout. Displays file metadata, processing status with
* a progress indicator, and a chronological list of processing log entries.
*/
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import type { FileDetail } from "../services/api";
import api from "../services/api";
import { useLocale, t } from "../i18n";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function statusColor(status: string): string {
const colors: Record<string, string> = {
completed: "#059669",
processing: "#d97706",
pending: "#6b7280",
failed: "#dc2626",
duplicate: "#6b7280",
};
return colors[status?.toLowerCase()] ?? "#6b7280";
}
function statusIcon(status: string): keyof typeof Ionicons.glyphMap {
const icons: Record<string, keyof typeof Ionicons.glyphMap> = {
completed: "checkmark-circle",
processing: "sync-circle",
pending: "time-outline",
failed: "close-circle",
duplicate: "copy-outline",
};
return icons[status?.toLowerCase()] ?? "document-outline";
}
function logStepIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
const lower = status?.toLowerCase();
if (lower === "completed" || lower === "success") return { name: "checkmark-circle", color: "#059669" };
if (lower === "failed" || lower === "error") return { name: "close-circle", color: "#dc2626" };
if (lower === "skipped") return { name: "remove-circle-outline", color: "#9ca3af" };
if (lower === "processing" || lower === "running") return { name: "sync-circle", color: "#d97706" };
return { name: "ellipse-outline", color: "#6b7280" };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function FileDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [detail, setDetail] = useState<FileDetail | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Subscribe to language changes so translated strings re-render.
useLocale();
const fileId = parseInt(id ?? "0", 10);
const fetchDetail = useCallback(async () => {
if (!fileId) return;
try {
const data = await api.getFileDetail(fileId);
setDetail(data);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load file details");
}
}, [fileId]);
useEffect(() => {
(async () => {
setLoading(true);
await fetchDetail();
setLoading(false);
})();
}, [fetchDetail]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await fetchDetail();
setRefreshing(false);
}, [fetchDetail]);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
if (error || !detail) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error ?? t("file_detail.file_not_found")}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backButtonText}>{t("common.back")}</Text>
</Pressable>
</View>
);
}
const file = detail.file;
const status = detail.processing_status;
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
>
{/* Header with back button */}
<Pressable
style={styles.backRow}
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel={t("file_detail.back")}
>
<Ionicons name="arrow-back" size={20} color="#1e40af" />
<Text style={styles.backLabel}>{t("file_detail.back")}</Text>
</Pressable>
{/* File info card */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<Ionicons
name={statusIcon(status.status)}
size={28}
color={statusColor(status.status)}
style={{ marginRight: 12 }}
/>
<View style={{ flex: 1 }}>
<Text style={styles.filename} numberOfLines={2}>
{file.original_filename}
</Text>
<Text style={[styles.statusBadge, { color: statusColor(status.status) }]}>
{status.status.charAt(0).toUpperCase() + status.status.slice(1)}
</Text>
</View>
</View>
<View style={styles.metaGrid}>
<MetaRow label={t("file_detail.file_size")} value={formatBytes(file.file_size)} />
<MetaRow label={t("file_detail.mime_type")} value={file.mime_type ?? ""} />
<MetaRow label={t("file_detail.uploaded")} value={formatDateTime(file.created_at)} />
<MetaRow label={t("file_detail.file_hash")} value={file.filehash ? `${file.filehash.slice(0, 24)}` : ""} />
<MetaRow label={t("file_detail.last_step")} value={status.last_step ?? ""} />
<MetaRow label={t("file_detail.total_steps")} value={String(status.total_steps)} />
</View>
</View>
{/* Processing logs */}
<View style={styles.card}>
<Text style={styles.sectionTitle}>{t("file_detail.processing_log")}</Text>
{detail.logs.length === 0 ? (
<Text style={styles.emptyLog}>{t("file_detail.no_logs")}</Text>
) : (
detail.logs.map((log, idx) => {
const icon = logStepIcon(log.status);
const isLast = idx === detail.logs.length - 1;
return (
<View key={log.id} style={[styles.logEntry, !isLast && styles.logEntryBorder]}>
<Ionicons name={icon.name} size={18} color={icon.color} style={styles.logIcon} />
<View style={styles.logContent}>
<Text style={styles.logStep}>{log.step_name}</Text>
<Text style={styles.logMessage} numberOfLines={3}>
{log.message}
</Text>
<Text style={styles.logTimestamp}>{formatDateTime(log.timestamp)}</Text>
</View>
</View>
);
})
)}
</View>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{label}</Text>
<Text style={styles.metaValue} numberOfLines={1}>
{value}
</Text>
</View>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 16, paddingBottom: 40 },
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
padding: 24,
},
errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
retryButton: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingHorizontal: 24,
paddingVertical: 10,
marginBottom: 12,
},
retryText: { color: "#fff", fontWeight: "600" },
backButton: { paddingVertical: 10 },
backButtonText: { color: "#6b7280", fontSize: 14 },
backRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
minHeight: 44,
},
backLabel: {
fontSize: 15,
color: "#1e40af",
fontWeight: "600",
marginLeft: 6,
},
card: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 6,
elevation: 2,
},
cardHeader: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 16,
},
filename: {
fontSize: 17,
fontWeight: "700",
color: "#111827",
marginBottom: 4,
},
statusBadge: {
fontSize: 13,
fontWeight: "600",
textTransform: "capitalize",
},
metaGrid: {},
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
metaLabel: { fontSize: 13, color: "#6b7280", fontWeight: "500" },
metaValue: { fontSize: 13, color: "#374151", maxWidth: "55%", textAlign: "right" },
sectionTitle: {
fontSize: 15,
fontWeight: "700",
color: "#374151",
marginBottom: 12,
},
emptyLog: { fontSize: 13, color: "#9ca3af", fontStyle: "italic" },
logEntry: {
flexDirection: "row",
alignItems: "flex-start",
paddingVertical: 10,
},
logEntryBorder: {
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
logIcon: { marginRight: 10, marginTop: 1 },
logContent: { flex: 1 },
logStep: { fontSize: 13, fontWeight: "600", color: "#374151", marginBottom: 2 },
logMessage: { fontSize: 12, color: "#6b7280", lineHeight: 17, marginBottom: 2 },
logTimestamp: { fontSize: 11, color: "#9ca3af" },
});
+171 -54
View File
@@ -1,8 +1,10 @@
/**
* FilesScreen list of documents processed by DocuElevate.
* FilesScreen list of documents processed by DocuElevate with search.
*/
import React, { useCallback, useEffect, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
@@ -10,10 +12,12 @@ import {
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import type { FileRecord } from "../services/api";
import api from "../services/api";
import { useLocale, t } from "../i18n";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "";
@@ -34,29 +38,34 @@ function formatDate(iso: string): string {
}
}
function statusEmoji(status: string): string {
const map: Record<string, string> = {
completed: "✅",
processing: "⚙️",
pending: "⏳",
failed: "❌",
duplicate: "🔁",
function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
const map: Record<string, { name: keyof typeof Ionicons.glyphMap; color: string }> = {
completed: { name: "checkmark-circle", color: "#059669" },
processing: { name: "sync-circle", color: "#d97706" },
pending: { name: "time-outline", color: "#6b7280" },
failed: { name: "close-circle", color: "#dc2626" },
duplicate: { name: "copy-outline", color: "#6b7280" },
};
return map[status?.toLowerCase()] ?? "📄";
return map[status?.toLowerCase()] ?? { name: "document-outline", color: "#6b7280" };
}
export default function FilesScreen() {
const router = useRouter();
const [files, setFiles] = useState<FileRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Subscribe to language changes so translated strings re-render.
useLocale();
const fetchFiles = useCallback(
async (pageNum: number, replace: boolean) => {
async (pageNum: number, replace: boolean, search?: string) => {
try {
const data = await api.listFiles(pageNum, 20);
const data = await api.listFiles(pageNum, 20, search || undefined);
if (replace) {
setFiles(data);
} else {
@@ -82,18 +91,56 @@ export default function FilesScreen() {
const handleRefresh = useCallback(async () => {
setRefreshing(true);
setPage(1);
await fetchFiles(1, true);
await fetchFiles(1, true, searchQuery);
setRefreshing(false);
}, [fetchFiles]);
}, [fetchFiles, searchQuery]);
const handleLoadMore = useCallback(async () => {
if (!hasMore || loading || refreshing) return;
const next = page + 1;
setPage(next);
await fetchFiles(next, false);
}, [fetchFiles, hasMore, loading, page, refreshing]);
await fetchFiles(next, false, searchQuery);
}, [fetchFiles, hasMore, loading, page, refreshing, searchQuery]);
if (loading) {
const handleSearch = useCallback(
(text: string) => {
setSearchQuery(text);
// Debounce search requests
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setPage(1);
setLoading(true);
try {
await fetchFiles(1, true, text);
} finally {
setLoading(false);
}
}, 400);
},
[fetchFiles]
);
const handleClearSearch = useCallback(async () => {
setSearchQuery("");
setPage(1);
setLoading(true);
try {
await fetchFiles(1, true);
} finally {
setLoading(false);
}
}, [fetchFiles]);
const handleFilePress = useCallback(
(file: FileRecord) => {
router.push({ pathname: "/(tabs)/file-detail", params: { id: String(file.id) } });
},
[router]
);
if (loading && files.length === 0) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1e40af" />
@@ -101,52 +148,88 @@ export default function FilesScreen() {
);
}
if (error) {
if (error && files.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>Retry</Text>
<Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable>
</View>
);
}
return (
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>📂</Text>
<Text style={styles.emptyText}>No documents yet.</Text>
<Text style={styles.emptyHint}>
Upload a document from the Upload tab to get started.
</Text>
</View>
}
ListFooterComponent={
hasMore && files.length > 0 ? (
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
) : null
}
/>
<View style={styles.container}>
{/* Search bar */}
<View style={styles.searchContainer}>
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder={t("files.search_placeholder")}
placeholderTextColor="#9ca3af"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
accessibilityLabel={t("common.search")}
/>
{searchQuery.length > 0 && (
<Pressable
onPress={handleClearSearch}
style={styles.clearButton}
accessibilityRole="button"
accessibilityLabel={t("common.clear_search")}
>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
)}
</View>
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} onPress={handleFilePress} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>
{searchQuery ? t("files.search_empty") : t("files.empty_title")}
</Text>
<Text style={styles.emptyHint}>
{searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
</Text>
</View>
}
ListFooterComponent={
hasMore && files.length > 0 ? (
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
) : null
}
/>
</View>
);
}
function FileRow({ file }: { file: FileRecord }) {
function FileRow({ file, onPress }: { file: FileRecord; onPress: (file: FileRecord) => void }) {
const status = file.processing_status?.status ?? "pending";
const icon = statusIcon(status);
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text>
<Pressable
style={rowStyles.row}
onPress={() => onPress(file)}
accessibilityRole="button"
accessibilityLabel={`View details for ${file.original_filename}`}
>
<Ionicons name={icon.name} size={22} color={icon.color} style={rowStyles.icon} />
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{file.original_filename}
@@ -155,14 +238,44 @@ function FileRow({ file }: { file: FileRecord }) {
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
</Text>
</View>
<Text style={rowStyles.status}>{status}</Text>
</View>
<View style={rowStyles.right}>
<Text style={rowStyles.status}>{status}</Text>
<Ionicons name="chevron-forward" size={16} color="#d1d5db" />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
list: { flex: 1, backgroundColor: "#f9fafb" },
listContent: { padding: 16 },
container: { flex: 1, backgroundColor: "#f9fafb" },
list: { flex: 1 },
listContent: { padding: 16, paddingTop: 0 },
searchContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
marginHorizontal: 16,
marginVertical: 12,
borderRadius: 10,
paddingHorizontal: 12,
borderWidth: 1,
borderColor: "#e5e7eb",
minHeight: 44,
},
searchIcon: { marginRight: 8 },
searchInput: {
flex: 1,
fontSize: 15,
color: "#111827",
paddingVertical: 10,
},
clearButton: {
padding: 4,
minWidth: 44,
minHeight: 44,
alignItems: "center",
justifyContent: "center",
},
center: {
flex: 1,
alignItems: "center",
@@ -179,7 +292,6 @@ const styles = StyleSheet.create({
},
retryText: { color: "#fff", fontWeight: "600" },
emptyState: { alignItems: "center", paddingTop: 60 },
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
emptyHint: {
fontSize: 13,
@@ -203,7 +315,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
@@ -212,6 +324,11 @@ const rowStyles = StyleSheet.create({
marginBottom: 4,
},
meta: { fontSize: 12, color: "#6b7280" },
right: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
status: {
fontSize: 11,
color: "#6b7280",
+83 -21
View File
@@ -24,13 +24,16 @@ import {
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useLocale, t } from "../i18n";
export default function LoginScreen() {
const { signIn, signInWithQR } = useAuth();
const router = useRouter();
const [serverUrl, setServerUrl] = useState("");
const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org");
const [loading, setLoading] = useState(false);
const [qrLoading, setQrLoading] = useState(false);
// Subscribe to language changes so translated strings re-render.
useLocale();
// Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
const handleDeepLink = useCallback(
@@ -46,8 +49,8 @@ export default function LoginScreen() {
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "QR login failed";
Alert.alert("QR Login Failed", message);
const message = err instanceof Error ? err.message : t("login.qr_login_failed");
Alert.alert(t("login.qr_login_failed"), message);
} finally {
setQrLoading(false);
}
@@ -70,11 +73,11 @@ export default function LoginScreen() {
async function handleSignIn() {
const url = serverUrl.trim();
if (!url) {
Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
Alert.alert(t("login.server_url_required"), t("login.server_url_required_msg"));
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
Alert.alert(t("login.invalid_url"), t("login.invalid_url_msg"));
return;
}
@@ -82,8 +85,8 @@ export default function LoginScreen() {
try {
await signIn(url);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Sign-in failed";
Alert.alert("Sign-in failed", message);
const message = err instanceof Error ? err.message : t("login.sign_in_failed");
Alert.alert(t("login.sign_in_failed"), message);
} finally {
setLoading(false);
}
@@ -104,12 +107,12 @@ export default function LoginScreen() {
/>
<Text style={styles.logoText}>DocuElevate</Text>
</View>
<Text style={styles.tagline}>Intelligent Document Processing</Text>
<Text style={styles.tagline}>{t("welcome.tagline")}</Text>
<Text style={styles.label}>Server URL</Text>
<Text style={styles.label}>{t("login.server_url")}</Text>
<TextInput
style={styles.input}
placeholder="https://your-docuelevate-server.com"
placeholder={t("login.server_url_placeholder")}
placeholderTextColor="#9ca3af"
value={serverUrl}
onChangeText={setServerUrl}
@@ -118,7 +121,7 @@ export default function LoginScreen() {
keyboardType="url"
returnKeyType="go"
onSubmitEditing={handleSignIn}
accessibilityLabel="Server URL"
accessibilityLabel={t("login.server_url")}
/>
<Pressable
@@ -126,18 +129,18 @@ export default function LoginScreen() {
onPress={handleSignIn}
disabled={loading || qrLoading}
accessibilityRole="button"
accessibilityLabel="Sign in with SSO"
accessibilityLabel={t("login.sign_in_sso")}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign in with SSO</Text>
<Text style={styles.buttonText}>{t("login.sign_in_sso")}</Text>
)}
</Pressable>
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
<Text style={styles.dividerText}>{t("login.or")}</Text>
<View style={styles.dividerLine} />
</View>
@@ -148,27 +151,64 @@ export default function LoginScreen() {
}}
disabled={loading || qrLoading}
accessibilityRole="button"
accessibilityLabel="Sign in with QR code"
accessibilityLabel={t("login.scan_qr")}
>
{qrLoading ? (
<ActivityIndicator color="#1e40af" />
) : (
<Text style={styles.qrButtonText}>📱 Scan QR Code to Login</Text>
<Text style={styles.qrButtonText}>{t("login.scan_qr")}</Text>
)}
</Pressable>
<Text style={styles.hint}>
Sign in via SSO or scan a QR code from the web app.
</Text>
<Text style={styles.hint}>{t("login.hint")}</Text>
<Pressable
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Back to welcome screen"
accessibilityLabel={t("login.back")}
style={styles.backLink}
>
<Text style={styles.backLinkText}> Back</Text>
<Text style={styles.backLinkText}>{t("login.back")}</Text>
</Pressable>
{/* Legal links accessible pre-login for GDPR / Apple compliance */}
<View style={styles.legalLinks}>
<Pressable
onPress={() => {
const base = serverUrl.trim() || "https://app.docuelevate.org";
Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
}}
accessibilityRole="link"
accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => {
const base = serverUrl.trim() || "https://app.docuelevate.org";
Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
}}
accessibilityRole="link"
accessibilityLabel={t("legal.terms")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => {
const base = serverUrl.trim() || "https://app.docuelevate.org";
Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
}}
accessibilityRole="link"
accessibilityLabel={t("legal.imprint")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
);
@@ -291,4 +331,26 @@ const styles = StyleSheet.create({
fontSize: 13,
color: "#6b7280",
},
legalLinks: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: 16,
flexWrap: "wrap",
},
legalLinkButton: {
minHeight: 44,
justifyContent: "center",
paddingHorizontal: 4,
},
legalLinkText: {
fontSize: 12,
color: "#9ca3af",
textDecorationLine: "underline",
},
legalSeparator: {
fontSize: 12,
color: "#d1d5db",
marginHorizontal: 4,
},
});
+222 -14
View File
@@ -2,6 +2,8 @@
* ProfileScreen authenticated user profile and settings.
*/
import Constants from "expo-constants";
import * as Linking from "expo-linking";
import React from "react";
import {
Alert,
@@ -9,30 +11,84 @@ import {
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useLocale, getSupportedLanguages, t } from "../i18n";
import api from "../services/api";
const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth();
const { lang, setLang } = useLocale();
const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
const languages = getSupportedLanguages();
async function handleLanguageSelect(code: string) {
await setLang(code);
// Fire-and-forget: sync the choice to the server so it persists across
// platforms (desktop web will reflect this preference too).
api.setServerLanguage(code).catch(() => {
// Network errors are non-critical the local change is already applied.
});
}
function handleSignOut() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
Alert.alert(t("profile.sign_out_title"), t("profile.sign_out_msg"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: "Sign out",
text: t("profile.sign_out"),
style: "destructive",
onPress: signOut,
},
]);
}
function handleDeleteAccount() {
Alert.alert(
t("profile.delete_account_title"),
t("profile.delete_account_msg"),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("profile.delete_account"),
style: "destructive",
onPress: () => {
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.delete_account") }));
});
},
},
]
);
}
function openPrivacyPolicy() {
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.privacy_policy") }));
});
}
function openTermsOfService() {
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.terms_of_service") }));
});
}
function openImprint() {
Linking.openURL(`${effectiveBaseUrl}/imprint`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.imprint") }));
});
}
if (!user) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Not signed in</Text>
<Text style={styles.emptyText}>{t("profile.not_signed_in")}</Text>
</View>
);
}
@@ -56,44 +112,121 @@ export default function ProfileScreen() {
)}
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
{user.email && <Text style={styles.email}>{user.email}</Text>}
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
{user.is_admin && <Text style={styles.adminBadge}>{t("profile.admin")}</Text>}
</View>
{/* Server info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Connection</Text>
<Text style={styles.sectionTitle}>{t("profile.connection")}</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text>
<Text style={styles.rowLabel}>{t("profile.server")}</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{baseUrl || ""}
{effectiveBaseUrl}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.rowLabel}>User ID</Text>
<Text style={styles.rowLabel}>{t("profile.user_id")}</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{user.owner_id}
</Text>
</View>
</View>
{/* Danger zone */}
{/* Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("profile.settings")}</Text>
<Text style={styles.settingLabel}>{t("profile.language")}</Text>
<View style={styles.languageGrid}>
{languages.map((l) => (
<Pressable
key={l.code}
style={[
styles.languageChip,
lang === l.code && styles.languageChipActive,
]}
onPress={() => handleLanguageSelect(l.code)}
accessibilityRole="button"
accessibilityLabel={`Set language to ${l.label}`}
accessibilityState={{ selected: lang === l.code }}
>
<Text
style={[
styles.languageChipText,
lang === l.code && styles.languageChipTextActive,
]}
>
{l.label}
</Text>
</Pressable>
))}
</View>
</View>
{/* Legal & Privacy */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("profile.legal")}</Text>
<Pressable
style={styles.linkRow}
onPress={openPrivacyPolicy}
accessibilityRole="link"
accessibilityLabel={t("profile.privacy_policy")}
>
<Text style={styles.linkText}>{t("profile.privacy_policy")}</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
<Pressable
style={styles.linkRow}
onPress={openTermsOfService}
accessibilityRole="link"
accessibilityLabel={t("profile.terms_of_service")}
>
<Text style={styles.linkText}>{t("profile.terms_of_service")}</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
<Pressable
style={[styles.linkRow, styles.linkRowLast]}
onPress={openImprint}
accessibilityRole="link"
accessibilityLabel={t("profile.imprint")}
>
<Text style={styles.linkText}>{t("profile.imprint")}</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
</View>
{/* Sign out */}
<View style={styles.section}>
<Pressable
style={styles.signOutButton}
onPress={handleSignOut}
accessibilityRole="button"
accessibilityLabel="Sign out"
accessibilityLabel={t("profile.sign_out")}
>
<Text style={styles.signOutText}>Sign out</Text>
<Text style={styles.signOutText}>{t("profile.sign_out")}</Text>
</Pressable>
</View>
{/* Account deletion Apple Guideline 5.1.1(v) */}
<View style={styles.section}>
<Pressable
style={styles.deleteAccountButton}
onPress={handleDeleteAccount}
accessibilityRole="button"
accessibilityLabel={t("profile.delete_account")}
>
<Text style={styles.deleteAccountText}>{t("profile.delete_account")}</Text>
</Pressable>
</View>
{/* App version */}
<Text style={styles.versionText}>DocuElevate v{appVersion}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 20 },
content: { padding: 20, paddingBottom: 40 },
center: {
flex: 1,
alignItems: "center",
@@ -180,6 +313,27 @@ const styles = StyleSheet.create({
maxWidth: "60%",
textAlign: "right",
},
linkRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
minHeight: 44,
},
linkRowLast: {
borderBottomWidth: 0,
},
linkText: {
fontSize: 15,
color: "#1e40af",
},
linkChevron: {
fontSize: 18,
color: "#9ca3af",
fontWeight: "600",
},
signOutButton: {
backgroundColor: "#fee2e2",
borderRadius: 10,
@@ -192,4 +346,58 @@ const styles = StyleSheet.create({
fontWeight: "700",
fontSize: 15,
},
deleteAccountButton: {
backgroundColor: "#ffffff",
borderRadius: 10,
borderWidth: 1,
borderColor: "#dc2626",
paddingVertical: 14,
alignItems: "center",
minHeight: 48,
},
deleteAccountText: {
color: "#dc2626",
fontWeight: "600",
fontSize: 14,
},
versionText: {
fontSize: 12,
color: "#9ca3af",
textAlign: "center",
marginTop: 8,
},
settingLabel: {
fontSize: 14,
color: "#374151",
fontWeight: "500",
marginBottom: 10,
},
languageGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
languageChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: "#f3f4f6",
borderWidth: 1,
borderColor: "#e5e7eb",
minHeight: 36,
justifyContent: "center",
},
languageChipActive: {
backgroundColor: "#dbeafe",
borderColor: "#1e40af",
},
languageChipText: {
fontSize: 13,
color: "#6b7280",
fontWeight: "500",
},
languageChipTextActive: {
color: "#1e40af",
fontWeight: "700",
},
});
+162 -66
View File
@@ -12,7 +12,9 @@
* track the real-time processing status of each uploaded file.
*/
import { Ionicons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import * as ImagePicker from "expo-image-picker";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -26,7 +28,9 @@ import {
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useShare } from "../context/ShareContext";
import { normalizeFileUri } from "../utils/normalizeUri";
import api from "../services/api";
import { useLocale, t } from "../i18n";
/** Statuses that indicate processing has finished (no further polling needed). */
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
@@ -53,6 +57,8 @@ export default function UploadScreen() {
const { isAuthenticated } = useAuth();
const { pendingFiles, clearPendingFiles } = useShare();
const [uploads, setUploads] = useState<UploadItem[]>([]);
// Subscribe to language changes so translated strings re-render.
useLocale();
// Keep a ref in sync so the polling interval can read current state without
// capturing a stale closure.
@@ -61,30 +67,102 @@ export default function UploadScreen() {
uploadsRef.current = uploads;
}, [uploads]);
// Track URIs that have already been uploaded in this session so that
// duplicate share-sheet deliveries (iOS can fire both the Linking handler
// and +not-found.tsx for the same file) do not trigger repeated uploads.
const uploadedUrisRef = useRef<Set<string>>(new Set());
// ---------------------------------------------------------------------------
// Core helpers (declared before the effects that depend on them)
// ---------------------------------------------------------------------------
/**
* Ensure a file URI is accessible for upload.
*
* Files received via the iOS Share Sheet / "Open In…" may reference paths
* outside the app's sandbox or use security-scoped URLs that React Native's
* fetch cannot read directly. This helper copies such files to the app's
* cache directory so the upload can proceed reliably.
*
* URIs from expo-image-picker and expo-document-picker are already in the
* app's cache and are returned unchanged.
*/
const ensureLocalUri = useCallback(async (uri: string, filename: string): Promise<string> => {
// Android content:// URIs are handled natively by React Native's fetch.
if (!uri.startsWith("file://")) return uri;
// Files already in the app's cache or documents directory are accessible.
const cacheDir = FileSystem.cacheDirectory;
const docDir = FileSystem.documentDirectory;
if (cacheDir && uri.startsWith(cacheDir)) return uri;
if (docDir && uri.startsWith(docDir)) return uri;
// External file (e.g. from iOS Inbox or security-scoped URL) copy to
// cache so the upload has guaranteed read access.
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const destUri = `${cacheDir}shared_${Date.now()}_${safeName}`;
try {
await FileSystem.copyAsync({ from: uri, to: destUri });
return destUri;
} catch (copyErr) {
// Copy failed fall back to the original URI (might work for some paths).
console.warn("[ensureLocalUri] copyAsync failed:", { from: uri, to: destUri, error: copyErr });
return uri;
}
}, []);
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
const id = `${Date.now()}-${filename}`;
// Deduplicate: skip if this exact URI was already uploaded in this session.
// This guards against duplicate share-sheet deliveries from iOS where the
// Linking handler and +not-found.tsx fire for the same file.
const normUri = normalizeFileUri(uri);
if (uploadedUrisRef.current.has(normUri)) {
console.debug("[uploadFile] skipping duplicate URI:", uri);
return;
}
uploadedUrisRef.current.add(normUri);
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${filename}`;
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
try {
const resp = await api.uploadFile(uri, filename, mimeType);
setUploads((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: item
)
);
const localUri = await ensureLocalUri(uri, filename);
const resp = await api.uploadFile(localUri, filename, mimeType);
if (resp.status === "duplicate" && resp.duplicate_of) {
// Server rejected the file as a known duplicate — mark as done and
// set the server-side status to "duplicate" so it appears as a
// terminal status and is not polled further.
setUploads((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
status: "done",
fileId: resp.duplicate_of!.original_file_id,
originalFilename: resp.original_filename,
serverStatus: "duplicate",
}
: item
)
);
} else {
setUploads((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: item
)
);
}
} catch (err: unknown) {
// Allow retrying this URI on failure.
uploadedUrisRef.current.delete(normUri);
const msg = err instanceof Error ? err.message : "Upload failed";
setUploads((prev) =>
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
);
}
}, []);
}, [ensureLocalUri]);
const retryUpload = useCallback(async (item: UploadItem) => {
if (!item.uri) return;
@@ -99,21 +177,38 @@ export default function UploadScreen() {
);
try {
const resp = await api.uploadFile(item.uri, item.filename, item.mimeType);
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: u
)
);
const localUri = await ensureLocalUri(item.uri, item.filename);
const resp = await api.uploadFile(localUri, item.filename, item.mimeType);
if (resp.status === "duplicate" && resp.duplicate_of) {
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? {
...u,
status: "done",
fileId: resp.duplicate_of!.original_file_id,
originalFilename: resp.original_filename,
serverStatus: "duplicate",
}
: u
)
);
} else {
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: u
)
);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Upload failed";
setUploads((prev) =>
prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u))
);
}
}, []);
}, [ensureLocalUri]);
// ---------------------------------------------------------------------------
// Polling check server-side processing status every 5 seconds
@@ -176,8 +271,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Camera access required",
"Please grant camera access in Settings to capture documents."
t("upload.camera_access_title"),
t("upload.camera_access_msg")
);
return;
}
@@ -199,8 +294,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Photo library access required",
"Please grant photo library access in Settings to select images."
t("upload.photo_access_title"),
t("upload.photo_access_msg")
);
return;
}
@@ -209,14 +304,17 @@ export default function UploadScreen() {
mediaTypes: ["images"],
quality: 0.9,
allowsEditing: false,
allowsMultipleSelection: true,
});
if (!result.canceled && result.assets.length > 0) {
const asset = result.assets[0];
// Derive extension from MIME type so the filename matches the actual format
const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
const filename = asset.fileName ?? `photo_${Date.now()}.${ext}`;
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
for (let i = 0; i < result.assets.length; i++) {
const asset = result.assets[i];
// Derive extension from MIME type so the filename matches the actual format
const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
const filename = asset.fileName ?? `photo_${Date.now()}_${i}.${ext}`;
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
}
}
}
@@ -234,14 +332,14 @@ export default function UploadScreen() {
}
}
} catch (err: unknown) {
Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
Alert.alert(t("upload.file_picker_error"), err instanceof Error ? err.message : t("upload.file_picker_error_msg"));
}
}
if (!isAuthenticated) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Please sign in to upload documents.</Text>
<Text style={styles.emptyText}>{t("upload.sign_in_required")}</Text>
</View>
);
}
@@ -254,30 +352,30 @@ export default function UploadScreen() {
style={[styles.actionButton, styles.cameraButton]}
onPress={handleCamera}
accessibilityRole="button"
accessibilityLabel="Capture document with camera"
accessibilityLabel={t("upload.capture_label")}
>
<Text style={styles.actionIcon}>📷</Text>
<Text style={styles.actionLabel}>Camera</Text>
<Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>{t("upload.camera")}</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.photoLibraryButton]}
onPress={handlePhotoLibrary}
accessibilityRole="button"
accessibilityLabel="Select photo from library"
accessibilityLabel={t("upload.photo_label")}
>
<Text style={styles.actionIcon}>🖼</Text>
<Text style={styles.actionLabel}>Photos</Text>
<Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>{t("upload.photos")}</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.fileButton]}
onPress={handleFilePicker}
accessibilityRole="button"
accessibilityLabel="Pick file from device"
accessibilityLabel={t("upload.file_label")}
>
<Text style={styles.actionIcon}>📄</Text>
<Text style={styles.actionLabel}>Files</Text>
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>{t("upload.files")}</Text>
</Pressable>
</View>
@@ -285,13 +383,9 @@ export default function UploadScreen() {
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{uploads.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}></Text>
<Text style={styles.emptyText}>
Tap Camera, Photos, or Files to upload a document.
</Text>
<Text style={styles.emptyHint}>
You can also share files from other apps directly to DocuElevate.
</Text>
<Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>{t("upload.empty_title")}</Text>
<Text style={styles.emptyHint}>{t("upload.empty_hint")}</Text>
</View>
) : (
uploads.map((item) => (
@@ -304,21 +398,24 @@ export default function UploadScreen() {
}
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
const uploadIcons: Record<UploadItem["status"], string> = {
pending: "⏳",
uploading: "⬆️",
done: "✅",
error: "❌",
// Subscribe to language changes so status labels re-render.
useLocale();
const uploadIconProps: Record<UploadItem["status"], { name: keyof typeof Ionicons.glyphMap; color: string }> = {
pending: { name: "time-outline", color: "#6b7280" },
uploading: { name: "arrow-up-circle-outline", color: "#1e40af" },
done: { name: "checkmark-circle", color: "#059669" },
error: { name: "close-circle", color: "#dc2626" },
};
/** Human-readable label for the server-side processing status. */
function serverStatusLabel(s: string): string {
const labels: Record<string, string> = {
pending: "Queued for processing…",
processing: "Processing",
completed: "Processed ✓",
failed: "Processing failed",
duplicate: "Duplicate already processed",
pending: t("upload.status_queued"),
processing: t("upload.status_processing"),
completed: t("upload.status_completed"),
failed: t("upload.status_failed"),
duplicate: t("upload.status_duplicate"),
};
return labels[s] ?? s;
}
@@ -327,9 +424,9 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
function handleLongPress() {
if (!canRetry) return;
Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [
{ text: "Cancel", style: "cancel" },
{ text: "Retry", onPress: () => onRetry(item) },
Alert.alert(t("upload.retry_title"), t("upload.retry_msg", { filename: item.filename }), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.retry"), onPress: () => onRetry(item) },
]);
}
@@ -339,10 +436,10 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
onPress={canRetry ? () => onRetry(item) : undefined}
style={rowStyles.row}
accessibilityRole={canRetry ? "button" : "none"}
accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
accessibilityLabel={canRetry ? `${t("common.retry")} ${item.filename}` : undefined}
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
>
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
<Ionicons name={uploadIconProps[item.status].name} size={22} color={uploadIconProps[item.status].color} style={rowStyles.icon} />
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{item.filename}
@@ -351,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
<ActivityIndicator size="small" color="#1e40af" />
)}
{item.status === "done" && !item.serverStatus && (
<Text style={rowStyles.statusQueued}>Queued for processing</Text>
<Text style={rowStyles.statusQueued}>{t("upload.status_queued")}</Text>
)}
{item.status === "done" && item.serverStatus && (
<Text
@@ -370,7 +467,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
<View>
<Text style={rowStyles.statusError}>{item.error}</Text>
{canRetry && (
<Text style={rowStyles.retryHint}>Tap to retry</Text>
<Text style={rowStyles.retryHint}>{t("upload.tap_retry")}</Text>
)}
</View>
)}
@@ -397,7 +494,7 @@ const styles = StyleSheet.create({
cameraButton: { backgroundColor: "#1e40af" },
photoLibraryButton: { backgroundColor: "#7c3aed" },
fileButton: { backgroundColor: "#059669" },
actionIcon: { fontSize: 28, marginBottom: 6 },
actionIcon: { marginBottom: 6 },
actionLabel: {
color: "#fff",
fontSize: 14,
@@ -409,7 +506,6 @@ const styles = StyleSheet.create({
alignItems: "center",
paddingTop: 60,
},
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: {
fontSize: 16,
color: "#374151",
@@ -443,7 +539,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
+81 -29
View File
@@ -6,6 +6,7 @@
*/
import { useRouter } from "expo-router";
import * as Linking from "expo-linking";
import React from "react";
import {
Image,
@@ -16,27 +17,31 @@ import {
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const FEATURES: { icon: string; title: string; description: string }[] = [
{
icon: "🔍",
title: "OCR & Text Extraction",
description: "Convert scanned PDFs and images into fully searchable text automatically.",
},
{
icon: "🤖",
title: "AI Metadata Extraction",
description: "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
},
{
icon: "☁️",
title: "Multi-Cloud Storage",
description: "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more.",
},
];
import { useLocale, t } from "../i18n";
export default function WelcomeScreen() {
const router = useRouter();
// Subscribe to language changes so translated strings re-render.
useLocale();
const features = [
{
icon: "🔍",
title: t("welcome.feature_ocr_title"),
description: t("welcome.feature_ocr_desc"),
},
{
icon: "🤖",
title: t("welcome.feature_ai_title"),
description: t("welcome.feature_ai_desc"),
},
{
icon: "☁️",
title: t("welcome.feature_cloud_title"),
description: t("welcome.feature_cloud_desc"),
},
];
return (
<SafeAreaView style={styles.safe}>
<ScrollView
@@ -54,16 +59,13 @@ export default function WelcomeScreen() {
/>
</View>
<Text style={styles.appName}>DocuElevate</Text>
<Text style={styles.tagline}>Intelligent Document Processing</Text>
<Text style={styles.heroDescription}>
Ingest documents, run OCR, extract metadata with AI, and route files
to your cloud storage all in one seamless pipeline.
</Text>
<Text style={styles.tagline}>{t("welcome.tagline")}</Text>
<Text style={styles.heroDescription}>{t("welcome.description")}</Text>
</View>
{/* Feature highlights */}
<View style={styles.features}>
{FEATURES.map((feature) => (
{features.map((feature) => (
<View key={feature.title} style={styles.featureRow}>
<Text style={styles.featureIcon} aria-hidden={true}>{feature.icon}</Text>
<View style={styles.featureText}>
@@ -79,14 +81,42 @@ export default function WelcomeScreen() {
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={() => router.push("/(auth)/login")}
accessibilityRole="button"
accessibilityLabel="Get started — connect to your DocuElevate server"
accessibilityLabel={t("welcome.get_started")}
>
<Text style={styles.buttonText}>Get Started</Text>
<Text style={styles.buttonText}>{t("welcome.get_started")}</Text>
</Pressable>
<Text style={styles.hint}>
Connect to your self-hosted or cloud DocuElevate server.
</Text>
<Text style={styles.hint}>{t("welcome.hint")}</Text>
{/* Legal links accessible pre-login for GDPR / Apple compliance */}
<View style={styles.legalLinks}>
<Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/privacy")}
accessibilityRole="link"
accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/terms")}
accessibilityRole="link"
accessibilityLabel={t("legal.terms")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/imprint")}
accessibilityRole="link"
accessibilityLabel={t("legal.imprint")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
@@ -206,4 +236,26 @@ const styles = StyleSheet.create({
color: "rgba(255,255,255,0.55)",
textAlign: "center",
},
legalLinks: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: 20,
flexWrap: "wrap",
},
legalLinkButton: {
minHeight: 44,
justifyContent: "center",
paddingHorizontal: 4,
},
legalLinkText: {
fontSize: 12,
color: "rgba(255,255,255,0.65)",
textDecorationLine: "underline",
},
legalSeparator: {
fontSize: 12,
color: "rgba(255,255,255,0.45)",
marginHorizontal: 4,
},
});
+44 -1
View File
@@ -26,6 +26,7 @@ export interface WhoAmIResponse {
email: string | null;
avatar_url: string | null;
is_admin: boolean;
preferred_language: string | null;
}
export interface GenerateTokenResponse {
@@ -66,10 +67,42 @@ export interface FileRecord {
}
export interface UploadResponse {
task_id: string;
task_id?: string;
status: string;
original_filename: string;
stored_filename: string;
duplicate_of?: {
duplicate_type: string;
original_file_id: number;
original_filename: string;
message: string;
};
}
export interface ProcessingLog {
id: number;
task_id: string;
step_name: string;
status: string;
message: string;
timestamp: string;
}
export interface FileDetail {
file: {
id: number;
filehash: string;
original_filename: string;
local_filename: string;
file_size: number;
mime_type: string;
created_at: string;
};
processing_status: ProcessingStatus;
logs: ProcessingLog[];
files_on_disk: {
original: boolean;
};
}
// ---------------------------------------------------------------------------
@@ -177,6 +210,11 @@ class DocuElevateAPI {
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
}
/** Sync the user's preferred UI language to the server. */
async setServerLanguage(lang: string): Promise<void> {
await this.request("POST", "/api/i18n/language", { body: { language: lang } });
}
// -------------------------------------------------------------------------
// Push notifications
// -------------------------------------------------------------------------
@@ -223,6 +261,11 @@ class DocuElevateAPI {
);
return data.processing_status;
}
/** Get full file details including processing logs. */
async getFileDetail(fileId: number): Promise<FileDetail> {
return this.request<FileDetail>("GET", `/api/files/${fileId}`);
}
}
export const api = new DocuElevateAPI();
+44
View File
@@ -0,0 +1,44 @@
/**
* Shared MIME type utilities for the DocuElevate mobile app.
*
* Used by the Linking handler in _layout.tsx, the catch-all +not-found.tsx,
* and any other code that needs to infer a MIME type from a file extension.
*/
/**
* Common MIME type mappings for file extensions.
* Used to infer the MIME type of files shared via the Share Sheet / "Open In…"
* so the server receives a correct Content-Type instead of application/octet-stream.
*/
export const EXT_TO_MIME: Record<string, string> = {
pdf: "application/pdf",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
webp: "image/webp",
heic: "image/heic",
heif: "image/heif",
txt: "text/plain",
csv: "text/csv",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
rtf: "application/rtf",
html: "text/html",
xml: "application/xml",
json: "application/json",
zip: "application/zip",
};
/** Infer MIME type from a filename's extension, or undefined if unknown. */
export function mimeTypeFromFilename(filename: string): string | undefined {
const ext = filename.split(".").pop()?.toLowerCase();
return ext ? EXT_TO_MIME[ext] : undefined;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Normalise a file URI for deduplication.
*
* - Decode percent-encoding (`%20` ` `)
* - Collapse consecutive slashes after the scheme (`file:////` `file:///`)
* - Strip trailing slashes
*/
export function normalizeFileUri(uri: string): string {
let norm: string;
try {
norm = decodeURIComponent(uri);
} catch {
norm = uri;
}
// Collapse multiple slashes after the scheme (e.g. file://// → file:///)
norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/");
// Strip trailing slash
norm = norm.replace(/\/+$/, "");
return norm;
}
+1
View File
@@ -60,3 +60,4 @@ sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
strawberry-graphql[fastapi]>=0.243.0,<1.0.0
aiofiles>=24.1.0 # Asynchronous file I/O support
segno>=1.6.0 # Pure-Python QR code generator (server-side rendering, no Pillow dependency)
+9
View File
@@ -116,6 +116,7 @@ def client(db_session) -> TestClient:
# Import the canonical get_db function
from app.database import get_db
from app.middleware.upload_rate_limit import require_upload_rate_limit
# Override the get_db dependency to use our test database
def override_get_db():
@@ -127,6 +128,14 @@ def client(db_session) -> TestClient:
# Override the single canonical get_db dependency
fastapi_app.dependency_overrides[get_db] = override_get_db
# Disable per-user upload rate limiting in tests so that upload-heavy
# test suites are not rejected with 429 Too Many Requests.
async def _no_rate_limit() -> None:
"""No-op override: skip upload rate limiting during tests."""
return None
fastapi_app.dependency_overrides[require_upload_rate_limit] = _no_rate_limit
# Use base_url to satisfy TrustedHostMiddleware
with TestClient(fastapi_app, base_url="http://localhost") as test_client:
yield test_client
+2
View File
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
".tif",
".webp",
".svg",
".heic",
".heif",
}
_html_extensions = {".html", ".htm"}
_markdown_extensions = {".md", ".markdown"}
+107
View File
@@ -416,3 +416,110 @@ class TestSaveDropboxSettings:
# .env write is best-effort; endpoint should still succeed via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
@pytest.mark.unit
class TestBuildDropboxRedirectUri:
"""Tests for the _build_dropbox_redirect_uri helper."""
def test_uses_public_base_url_when_set(self):
"""When PUBLIC_BASE_URL is configured, redirect URI should use it."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://myapp.example.com"
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://myapp.example.com/dropbox-callback"
def test_uses_public_base_url_strips_trailing_slash(self):
"""PUBLIC_BASE_URL with trailing slash should be handled correctly."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://myapp.example.com/"
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://myapp.example.com/dropbox-callback"
def test_falls_back_to_request_when_public_base_url_not_set(self):
"""When PUBLIC_BASE_URL is not set, use request scheme and netloc."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = None
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
mock_request.url.scheme = "https"
mock_request.url.netloc = "other.example.com"
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://other.example.com/dropbox-callback"
@pytest.mark.unit
class TestGlobalAuthorizeUrl:
"""Tests for GET /api/dropbox/global-authorize-url endpoint."""
@patch("app.api.dropbox.settings")
def test_returns_authorize_url(self, mock_settings, client):
"""Test that a valid authorize URL is returned when global creds are configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = "test-app-key"
mock_settings.dropbox_app_secret = "test-app-secret"
mock_settings.public_base_url = "https://example.com"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 200
data = response.json()
assert "authorize_url" in data
assert "https://www.dropbox.com/oauth2/authorize" in data["authorize_url"]
assert "client_id=test-app-key" in data["authorize_url"]
# redirect_uri should be URL-encoded
assert "redirect_uri=" in data["authorize_url"]
assert "https%3A%2F%2Fexample.com%2Fdropbox-callback" in data["authorize_url"]
@patch("app.api.dropbox.settings")
def test_returns_403_when_global_creds_disabled(self, mock_settings, client):
"""Test 403 when global credentials for integrations are disabled."""
mock_settings.dropbox_allow_global_credentials_for_integrations = False
mock_settings.dropbox_app_key = "test-app-key"
mock_settings.dropbox_app_secret = "test-app-secret"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 403
@patch("app.api.dropbox.settings")
def test_returns_503_when_creds_not_configured(self, mock_settings, client):
"""Test 503 when global Dropbox credentials are not configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = None
mock_settings.dropbox_app_secret = None
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 503
@patch("app.api.dropbox.settings")
def test_redirect_uri_uses_public_base_url(self, mock_settings, client):
"""Redirect URI in authorize URL must use PUBLIC_BASE_URL when configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = "my-key"
mock_settings.dropbox_app_secret = "my-secret"
mock_settings.public_base_url = "https://prod.example.com"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 200
authorize_url = response.json()["authorize_url"]
# The redirect_uri must be URL-encoded and contain the public base URL
assert "https%3A%2F%2Fprod.example.com%2Fdropbox-callback" in authorize_url
+79 -2
View File
@@ -887,9 +887,9 @@ class TestConnectionTestEndpoint:
def test_test_unsupported_type(self, int_client):
"""Unsupported integration types return a helpful non-error message."""
payload = {
"integration_type": "DROPBOX",
"integration_type": "FTP",
"config": {},
"credentials": {"token": "abc"},
"credentials": {"username": "user", "password": "pass"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
@@ -897,6 +897,83 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "not yet supported" in data["message"]
def test_test_dropbox_missing_refresh_token(self, int_client):
"""Dropbox test with missing refresh_token returns failure."""
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {"app_key": "key", "app_secret": "secret"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "refresh_token" in data["message"].lower()
def test_test_dropbox_missing_app_key(self, int_client):
"""Dropbox test with missing app_key/app_secret returns failure."""
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {"refresh_token": "rtoken"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "app_key" in data["message"].lower()
def test_test_dropbox_invalid_credentials(self, int_client):
"""Dropbox test with bad credentials returns an auth failure."""
from unittest.mock import MagicMock, patch
import dropbox.exceptions as dbx_exc
with patch("app.api.integrations.dbx_lib") as mock_dbx:
mock_instance = MagicMock()
mock_dbx.Dropbox.return_value = mock_instance
mock_instance.users_get_current_account.side_effect = dbx_exc.AuthError("req_id", MagicMock())
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {
"app_key": "bad_key",
"app_secret": "bad_secret",
"refresh_token": "bad_token",
},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "authentication failed" in data["message"].lower()
def test_test_dropbox_success(self, int_client):
"""Dropbox test with valid (mocked) credentials returns success."""
from unittest.mock import MagicMock, patch
with patch("app.api.integrations.dbx_lib") as mock_dbx:
mock_instance = MagicMock()
mock_dbx.Dropbox.return_value = mock_instance
mock_account = MagicMock()
mock_account.name.display_name = "Test User"
mock_instance.users_get_current_account.return_value = mock_account
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {
"app_key": "valid_key",
"app_secret": "valid_secret",
"refresh_token": "valid_token",
},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert "dropbox connection successful" in data["message"].lower()
def test_test_invalid_type_returns_400(self, int_client):
"""Invalid integration_type returns 400."""
payload = {
+66 -2
View File
@@ -329,7 +329,7 @@ class TestDeactivateDevice:
"""Tests for DELETE /api/mobile/devices/{device_id}."""
def test_deactivate_own_device(self, mob_engine, mob_session):
"""Deactivating a device sets is_active to False."""
"""Deactivating an active device sets is_active to False (soft-delete, returns 200)."""
from app.main import app
device = MobileDevice(
@@ -346,7 +346,8 @@ class TestDeactivateDevice:
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 204
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deactivated"
mob_session.expire_all()
updated = mob_session.get(MobileDevice, device_id)
@@ -355,6 +356,33 @@ class TestDeactivateDevice:
finally:
_cleanup(app)
def test_delete_inactive_device(self, mob_engine, mob_session):
"""Deleting an already-inactive device permanently removes it (hard-delete, returns 200)."""
from app.main import app
device = MobileDevice(
owner_id=_OWNER,
push_token=_EXPO_TOKEN,
platform="ios",
is_active=False,
)
mob_session.add(device)
mob_session.commit()
mob_session.refresh(device)
device_id = device.id
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deleted"
mob_session.expire_all()
deleted = mob_session.get(MobileDevice, device_id)
assert deleted is None
finally:
_cleanup(app)
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
"""Attempting to deactivate another user's device returns 404."""
from app.main import app
@@ -439,6 +467,42 @@ class TestWhoAmI:
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
assert data["preferred_language"] is None # not set yet
finally:
_cleanup(app)
def test_whoami_returns_preferred_language(self, mob_engine, mob_session):
"""preferred_language from UserProfile is included in the whoami response."""
from app.main import app
from app.models import UserProfile
profile = UserProfile(
user_id=_OWNER,
display_name="Bob Test",
preferred_language="de",
)
mob_session.add(profile)
mob_session.commit()
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["preferred_language"] == "de"
finally:
_cleanup(app)
def test_whoami_no_profile_preferred_language_is_null(self, mob_engine):
"""preferred_language is null when no UserProfile exists."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["preferred_language"] is None
finally:
_cleanup(app)
+223 -4
View File
@@ -314,8 +314,8 @@ class TestTokenRevoke:
_cleanup(app)
@pytest.mark.unit
def test_revoke_already_revoked_token(self, tok_engine):
"""Revoking an already-revoked token should return 400."""
def test_delete_already_revoked_token(self, tok_engine):
"""Deleting an already-revoked token should permanently remove it (hard-delete, 200)."""
from app.main import app
client = _make_client(tok_engine)
@@ -324,9 +324,15 @@ class TestTokenRevoke:
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
# Second DELETE should hard-delete the revoked token.
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already revoked"
assert resp.status_code == 200
assert resp.json()["detail"] == "Token deleted"
# Token must no longer appear in the list.
list_resp = client.get("/api/api-tokens/")
ids = [t["id"] for t in list_resp.json()]
assert token_id not in ids
finally:
_cleanup(app)
@@ -677,3 +683,216 @@ class TestTokenUtils:
token = "de_test_token_value"
expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e"
assert hash_token(token) == expected_hash
# ---------------------------------------------------------------------------
# Tests Token reactivation
# ---------------------------------------------------------------------------
class TestTokenReactivate:
"""Tests for POST /api/api-tokens/{id}/reactivate."""
@pytest.mark.unit
def test_reactivate_revoked_token(self, tok_engine):
"""Reactivating a revoked token should set is_active=True and clear revoked_at."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"})
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 200
data = resp.json()
assert data["is_active"] is True
assert data["revoked_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_active_token_returns_400(self, tok_engine):
"""Reactivating an already-active token should return 400."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"})
token_id = create_resp.json()["id"]
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already active"
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_nonexistent_token(self, tok_engine):
"""Reactivating a non-existent token should return 404."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/99999/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_other_users_token(self, tok_engine):
"""A user cannot reactivate another user's token."""
from app.main import app
client_a = _make_client(tok_engine, _OWNER)
try:
create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"})
token_id = create_resp.json()["id"]
client_a.delete(f"/api/api-tokens/{token_id}")
finally:
_cleanup(app)
client_b = _make_client(tok_engine, _OTHER_OWNER)
try:
resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Token lifetime (expires_at)
# ---------------------------------------------------------------------------
class TestTokenExpiry:
"""Tests for token creation with optional lifetime and expiry enforcement."""
@pytest.mark.unit
def test_create_token_without_expiry(self, tok_engine):
"""Creating a token without expires_in_days should leave expires_at as None."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "No Expiry"})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_with_expiry(self, tok_engine, tok_session):
"""Creating a token with expires_in_days should set expires_at in the future."""
from datetime import datetime, timezone
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is not None
# Parse the returned datetime; handle both tz-aware and tz-naive serialisations
expires_str = data["expires_at"].replace("Z", "+00:00")
expires_at = datetime.fromisoformat(expires_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
delta_days = (expires_at - now).days
assert 28 <= delta_days <= 30
finally:
_cleanup(app)
@pytest.mark.unit
def test_expired_token_not_resolved(self, tok_engine, tok_session):
"""A token past its expires_at should not authenticate."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Expired Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is None
@pytest.mark.unit
def test_non_expired_token_resolves(self, tok_engine, tok_session):
"""A token before its expires_at should authenticate normally."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Valid Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is not None
assert user["preferred_username"] == _OWNER
@pytest.mark.unit
def test_create_token_expires_in_days_zero_rejected(self, tok_engine):
"""expires_in_days=0 should be rejected with 422 (ge=1)."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0})
assert resp.status_code == 422
finally:
_cleanup(app)
@pytest.mark.unit
def test_expires_at_included_in_list_response(self, tok_engine):
"""List endpoint should include expires_at field."""
from app.main import app
client = _make_client(tok_engine)
try:
client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7})
resp = client.get("/api/api-tokens/")
assert resp.status_code == 200
tokens = resp.json()
assert len(tokens) == 1
assert "expires_at" in tokens[0]
assert tokens[0]["expires_at"] is not None
finally:
_cleanup(app)
+22
View File
@@ -337,6 +337,27 @@ class TestCSRFMiddlewareDispatch:
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_qr_auth_claim_is_exempt(self):
"""QR auth claim path is exempt from CSRF validation.
The mobile app calls this endpoint without a browser session and
therefore without a CSRF token. The cryptographically-random,
single-use challenge token provides equivalent protection.
"""
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/api/qr-auth/claim",
session={},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
# ---------------------------------------------------------------------------
# Integration tests via TestClient
@@ -362,6 +383,7 @@ class TestCSRFIntegration:
assert "PATCH" in CSRF_PROTECTED_METHODS
assert "GET" not in CSRF_PROTECTED_METHODS
assert "/oauth-callback" in CSRF_EXEMPT_PATHS
assert "/api/qr-auth/claim" in CSRF_EXEMPT_PATHS
def test_csrf_middleware_noop_when_auth_disabled(self):
"""When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""
+46
View File
@@ -998,3 +998,49 @@ class TestAlembicUpgrade:
# Verify head is reachable
heads = script.get_heads()
assert len(heads) == 1 # Should be a single linear chain
@pytest.mark.unit
class TestEnginePoolConfiguration:
"""Tests for database engine pool configuration (pool class and options)."""
def test_sqlite_engine_uses_null_pool(self):
"""SQLite engines must use NullPool to prevent QueuePool exhaustion."""
from sqlalchemy.pool import NullPool
from app.database import engine
# The test environment uses SQLite, so NullPool should be in effect.
assert isinstance(engine.pool, NullPool)
def test_create_engine_sqlite_null_pool(self):
"""Explicitly create a SQLite engine to confirm NullPool is applied."""
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool
test_engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
assert isinstance(test_engine.pool, NullPool)
test_engine.dispose()
def test_pool_settings_exist_in_config(self):
"""Verify that pool tuning settings are exposed through config."""
from app.config import settings
assert hasattr(settings, "db_pool_size")
assert hasattr(settings, "db_max_overflow")
assert hasattr(settings, "db_pool_timeout")
assert hasattr(settings, "db_pool_recycle")
def test_pool_settings_have_sensible_defaults(self):
"""Default pool settings should be larger than SQLAlchemy's built-in defaults."""
from app.config import settings
# SQLAlchemy defaults: pool_size=5, max_overflow=10
assert settings.db_pool_size >= 10
assert settings.db_max_overflow >= 20
assert settings.db_pool_timeout >= 30
assert settings.db_pool_recycle >= 1800
+80
View File
@@ -5,6 +5,86 @@ from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.unit
class TestLivenessProbe:
"""Tests for GET /api/diagnostic/healthz/live (unauthenticated)."""
def test_liveness_returns_200(self, client):
"""Liveness probe always returns 200 OK."""
response = client.get("/api/diagnostic/healthz/live")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
@pytest.mark.unit
class TestReadinessProbe:
"""Tests for GET /api/diagnostic/healthz/ready (unauthenticated)."""
def test_readiness_returns_200_when_all_ok(self, client):
"""Readiness probe returns 200 when database and Redis are reachable."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
mock_redis_inst = MagicMock()
mock_redis.from_url.return_value = mock_redis_inst
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["checks"]["database"]["status"] == "ok"
def test_readiness_returns_503_when_database_fails(self, client):
"""Readiness probe returns 503 when database is unreachable."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_engine.connect.side_effect = Exception("DB unavailable")
mock_redis_inst = MagicMock()
mock_redis.from_url.return_value = mock_redis_inst
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 503
data = response.json()
assert data["status"] == "not_ready"
assert data["checks"]["database"]["status"] == "error"
def test_readiness_returns_200_when_redis_fails(self, client):
"""Readiness remains 200 when only Redis is down (non-critical)."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
mock_redis.from_url.return_value = MagicMock()
mock_redis.from_url.return_value.ping.side_effect = Exception("Connection refused")
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["checks"]["redis"]["status"] == "error"
def test_readiness_contains_checks_keys(self, client):
"""Readiness response always contains database and redis checks."""
response = client.get("/api/diagnostic/healthz/ready")
data = response.json()
assert "checks" in data
assert "database" in data["checks"]
assert "redis" in data["checks"]
@pytest.mark.unit
class TestHealthEndpoint:
"""Tests for GET /api/diagnostic/health endpoint."""
+62 -26
View File
@@ -3,11 +3,12 @@
Covers:
- ``GET /api/duplicates`` list all exact-duplicate groups
- ``GET /api/files/{id}/duplicates`` per-file exact + near-duplicate info
- ``POST /api/ui-upload`` exact-duplicate warning in upload response
- ``POST /api/ui-upload`` exact-duplicate rejection at upload time
- ``GET /duplicates`` duplicate management UI page
"""
import json
import os
from unittest.mock import patch
import pytest
@@ -283,17 +284,25 @@ class TestGetFileDuplicates:
# ---------------------------------------------------------------------------
# POST /api/ui-upload — exact-duplicate warning
# POST /api/ui-upload — exact-duplicate rejection
# ---------------------------------------------------------------------------
class TestUploadDuplicateWarning:
"""Tests for duplicate warning injected into the upload response."""
class TestUploadDuplicateRejection:
"""Tests for duplicate rejection at upload time.
When ``ENABLE_DEDUPLICATION`` is ``True`` (the default) and the uploaded
file's SHA-256 hash matches an already-processed document, the upload
endpoint must:
- return ``status: "duplicate"`` instead of ``"queued"``
- **not** enqueue a Celery task
- clean up the temporary file from disk
"""
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_no_warning_for_unique_file(self, mock_delay, client: TestClient, tmp_path):
"""Uploading a unique file should not produce a duplicate_warning."""
"""Uploading a unique file should not produce a duplicate response."""
mock_delay.return_value.id = "task-unique"
pdf = tmp_path / "unique.pdf"
pdf.write_bytes(b"%PDF-1.4\n%%EOF")
@@ -306,14 +315,12 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
assert "duplicate_warning" not in data or data.get("duplicate_warning") is None
assert data["status"] == "queued"
assert "duplicate_of" not in data
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_warning_for_exact_duplicate(self, mock_delay, client: TestClient, db_session, tmp_path):
"""Uploading a file with the same hash as an existing record returns a warning."""
mock_delay.return_value.id = "task-dup"
def test_exact_duplicate_rejected(self, client: TestClient, db_session, tmp_path):
"""Uploading a file with the same hash as an existing record is rejected."""
# Create a real PDF with known content
pdf_bytes = b"%PDF-1.4\nsome unique content for test\n%%EOF"
pdf = tmp_path / "existing.pdf"
@@ -335,16 +342,14 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
assert "duplicate_warning" in data
assert data["duplicate_warning"]["duplicate_type"] == "exact"
assert data["duplicate_warning"]["original_file_id"] == existing.id
assert data["status"] == "duplicate"
assert "duplicate_of" in data
assert data["duplicate_of"]["duplicate_type"] == "exact"
assert data["duplicate_of"]["original_file_id"] == existing.id
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_upload_still_queued_despite_warning(self, mock_delay, client: TestClient, db_session, tmp_path):
"""Even when a duplicate is detected, the file should still be queued."""
mock_delay.return_value.id = "task-still-queued"
def test_duplicate_not_enqueued(self, client: TestClient, db_session, tmp_path):
"""When a duplicate is detected, no Celery task should be created."""
pdf_bytes = b"%PDF-1.4\nqueue test content\n%%EOF"
pdf = tmp_path / "queue_test.pdf"
pdf.write_bytes(pdf_bytes)
@@ -354,16 +359,47 @@ class TestUploadDuplicateWarning:
filehash = hash_file(str(pdf))
_make_file(db_session, filehash=filehash, filename="queue_orig.pdf")
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("queue_test.pdf", f, "application/pdf")},
)
with patch("app.tasks.process_document.process_document.delay") as mock_delay:
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("queue_test.pdf", f, "application/pdf")},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
assert data["status"] == "queued"
assert data["status"] == "duplicate"
assert "task_id" not in data
mock_delay.assert_not_called()
@pytest.mark.integration
def test_duplicate_temp_file_cleaned_up(self, client: TestClient, db_session, tmp_path):
"""The temporary file saved to disk should be removed for a duplicate."""
pdf_bytes = b"%PDF-1.4\ncleanup test content\n%%EOF"
pdf = tmp_path / "cleanup_test.pdf"
pdf.write_bytes(pdf_bytes)
from app.utils.file_operations import hash_file
filehash = hash_file(str(pdf))
_make_file(db_session, filehash=filehash, filename="cleanup_orig.pdf")
with patch("app.tasks.process_document.process_document.delay"):
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("cleanup_test.pdf", f, "application/pdf")},
)
assert response.status_code == 200
data = response.json()
# The stored_filename is returned so we can verify cleanup
stored = data.get("stored_filename")
assert stored is not None
from app.config import settings
assert not os.path.exists(os.path.join(settings.workdir, stored))
# ---------------------------------------------------------------------------
+29
View File
@@ -322,6 +322,35 @@ def test_signup_duplicate_username(la_client, active_user):
assert "Username" in resp.json()["detail"]
@pytest.mark.integration
def test_signup_invalid_username_with_dot(la_client):
"""POST /api/auth/signup returns 422 with a list detail when username contains a dot.
This is a regression test for the bug where ``data.detail`` was an array,
causing the frontend to display ``[object Object]`` instead of a message.
"""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "christian.louis",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 422
detail = resp.json()["detail"]
# FastAPI returns a list of validation errors for Pydantic constraint failures.
# Each entry must be a dict with a "msg" key so the frontend can extract a readable message.
assert isinstance(detail, list), "detail should be a list for Pydantic validation errors"
assert len(detail) > 0
assert "msg" in detail[0]
@pytest.mark.integration
def test_signup_smtp_failure_cleans_up(la_client, la_session):
"""POST /api/auth/signup cleans up user records if email send fails."""
+265
View File
@@ -0,0 +1,265 @@
"""Tests for per-user health-aware upload rate limiting (app/middleware/upload_rate_limit.py)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.middleware.upload_rate_limit import compute_effective_limit
# ---------------------------------------------------------------------------
# Tests for compute_effective_limit (pure function, no Redis needed)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComputeEffectiveLimit:
"""Tests for the health-aware effective-limit calculation."""
def test_normal_conditions_return_base_limit(self):
"""Under normal conditions the full base limit should be returned."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=0.0)
assert effective == 20
assert factor == 1.0
assert reason == "normal"
def test_moderate_queue_halves_limit(self):
"""Queue depth > 50 should halve the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=0.0)
assert effective == 10
assert factor == 0.5
assert "moderate_queue" in reason
def test_high_queue_quarters_limit(self):
"""Queue depth > 100 should quarter the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=120, cpu_load_ratio=0.0)
assert effective == 5
assert factor == 0.25
assert "high_queue" in reason
def test_critical_queue_drops_to_ten_percent(self):
"""Queue depth > 200 should drop to 10% of base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=250, cpu_load_ratio=0.0)
assert effective == 2
assert factor == 0.10
assert "critical_queue" in reason
def test_moderate_cpu_halves_limit(self):
"""CPU load ratio > 1.5 should halve the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=1.8)
assert effective == 10
assert factor == 0.5
assert "moderate_cpu" in reason
def test_high_cpu_quarters_limit(self):
"""CPU load ratio > 2.0 should quarter the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=2.5)
assert effective == 5
assert factor == 0.25
assert "high_cpu" in reason
def test_critical_cpu_drops_to_ten_percent(self):
"""CPU load ratio > 3.0 should drop to 10% of base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=4.0)
assert effective == 2
assert factor == 0.10
assert "critical_cpu" in reason
def test_worst_metric_wins(self):
"""The lowest factor from queue and CPU should be applied."""
# Queue says 0.5, CPU says 0.25 → 0.25 wins
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=2.5)
assert effective == 5
assert factor == 0.25
def test_minimum_effective_limit_is_one(self):
"""Even under extreme load the effective limit must be ≥ 1."""
effective, _factor, _reason = compute_effective_limit(1, queue_depth=999, cpu_load_ratio=10.0)
assert effective >= 1
def test_zero_base_limit_returns_zero(self):
"""A base limit of 0 (disabled) should clamp to at least 1."""
effective, _factor, _reason = compute_effective_limit(0, queue_depth=0, cpu_load_ratio=0.0)
# max(1, int(0 * 1.0)) = max(1, 0) = 1
# A base_limit of 0 means "disabled" and is handled upstream
# (the dependency skips the check entirely), but the pure function
# still clamps to 1 as a safety net.
assert effective == 1
# ---------------------------------------------------------------------------
# Tests for the FastAPI dependency (mocked Redis)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRequireUploadRateLimit:
"""Tests for the require_upload_rate_limit FastAPI dependency."""
@pytest.mark.asyncio
async def test_allows_request_when_redis_unavailable(self):
"""When Redis is down the dependency should fail open (allow the request)."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {}
mock_request.client = MagicMock()
mock_request.client.host = "127.0.0.1"
with patch("app.middleware.upload_rate_limit._get_redis", return_value=None):
# Should NOT raise
result = await require_upload_rate_limit(mock_request)
assert result is None
@pytest.mark.asyncio
async def test_allows_request_under_limit(self):
"""A user below the rate limit should be allowed through."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "testuser"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.1"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [
0, # zremrangebyscore result
5, # zcard — current count (under limit of 20)
[], # zrange oldest
]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0 # empty queues
mock_pipe2 = MagicMock()
mock_pipe2.execute.return_value = [True, True]
# The second pipeline call (record upload)
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="testuser"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.1),
):
result = await require_upload_rate_limit(mock_request)
assert result is None
@pytest.mark.asyncio
async def test_rejects_request_over_limit(self):
"""A user at or over the rate limit should receive a 429."""
from fastapi import HTTPException
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "spammer"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.2"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [
0, # zremrangebyscore
20, # zcard — at limit
[("oldest_entry", 1000000.0)], # oldest entry for retry_after
]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="spammer"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
with pytest.raises(HTTPException) as exc_info:
await require_upload_rate_limit(mock_request)
assert exc_info.value.status_code == 429
assert "Retry-After" in exc_info.value.headers
@pytest.mark.asyncio
async def test_health_reduces_effective_limit(self):
"""When queues are deep, the effective limit should drop, causing a 429 sooner."""
from fastapi import HTTPException
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "normaluser"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.3"
mock_redis = MagicMock()
mock_pipe = MagicMock()
# 12 uploads already — under normal limit of 20 but over health-reduced limit
mock_pipe.execute.return_value = [
0, # zremrangebyscore
12, # zcard — 12 uploads in window
[("oldest", 1000000.0)],
]
mock_redis.pipeline.return_value = mock_pipe
# Simulate deep queue (>100) → effective limit = 25% of 20 = 5
mock_redis.llen.return_value = 40 # 40 per queue * 3 = 120 total
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="normaluser"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
with pytest.raises(HTTPException) as exc_info:
await require_upload_rate_limit(mock_request)
assert exc_info.value.status_code == 429
@pytest.mark.asyncio
async def test_falls_back_to_ip_when_no_user(self):
"""Unauthenticated requests should use IP-based rate limiting."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {}
mock_request.client = MagicMock()
mock_request.client.host = "192.168.1.100"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [0, 0, []]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0
mock_pipe2 = MagicMock()
mock_pipe2.execute.return_value = [True, True]
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value=None),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
result = await require_upload_rate_limit(mock_request)
assert result is None
# ---------------------------------------------------------------------------
# Tests for configuration
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUploadRateLimitConfig:
"""Tests for upload rate limit configuration settings."""
def test_settings_exist(self):
"""Verify per-user upload rate limit settings are exposed in config."""
from app.config import settings
assert hasattr(settings, "upload_rate_limit_per_user")
assert hasattr(settings, "upload_rate_limit_window")
def test_sensible_defaults(self):
"""Default values should be reasonable for a multi-user system."""
from app.config import settings
assert settings.upload_rate_limit_per_user >= 10
assert settings.upload_rate_limit_per_user <= 100
assert settings.upload_rate_limit_window >= 30
assert settings.upload_rate_limit_window <= 300
+34
View File
@@ -144,3 +144,37 @@ class TestDropboxViews:
assert response.status_code == 200
assert b"/Documents/Uploads" in response.content
assert b"Back to Integrations" in response.content
@pytest.mark.integration
class TestDropboxCallbackUrl:
"""Tests that the callback_url is correctly passed to templates."""
def test_setup_page_includes_callback_url(self, client):
"""Setup page should include the callback_url variable in its response."""
response = client.get("/dropbox-setup")
assert response.status_code == 200
# callback_url is embedded in the JS as the dropboxCallbackUrl constant
assert b"dropboxCallbackUrl" in response.content
def test_callback_page_includes_callback_url(self, client):
"""Callback page should embed the server-side callback URL."""
response = client.get("/dropbox-callback?code=testcode")
assert response.status_code == 200
# callback_url is used as the redirectUri
assert b"redirectUri" in response.content
def test_setup_page_uses_public_base_url_when_set(self, client):
"""When PUBLIC_BASE_URL is configured, it should appear in the redirect URI hint."""
with patch("app.views.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://configured.example.com"
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = ""
mock_settings.dropbox_refresh_token = ""
mock_settings.dropbox_folder = ""
mock_settings.dropbox_allow_global_credentials_for_integrations = False
response = client.get("/dropbox-setup")
assert response.status_code == 200
# The configured public_base_url hostname must appear in the page (redirect URI display)
page_text = response.text
assert "configured.example.com/dropbox-callback" in page_text