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:
@@ -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
@@ -1 +1 @@
|
||||
2026-03-17T13:12:02Z
|
||||
2026-03-20T12:55:52Z
|
||||
|
||||
+217
@@ -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
|
||||
|
||||
+6
-6
@@ -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
|
||||
==============================
|
||||
|
||||
+68
-11
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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` (1–3650). 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
|
||||
|
||||
|
||||
@@ -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/)
|
||||
@@ -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 50–100 or CPU > 1.5× |
|
||||
| High load | 25 % of base | Queue 100–200 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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
+4
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* File detail route – displays processing status and logs for a single file.
|
||||
*/
|
||||
export { default } from "../../src/screens/FileDetailScreen";
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("eslint-config-expo/flat");
|
||||
Generated
+122
-437
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
}
|
||||
_html_extensions = {".html", ".htm"}
|
||||
_markdown_extensions = {".md", ".markdown"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user