fix: restore all code deleted/truncated by d2217531 Jules SSRF commit

Commit d2217531 (google-labs-jules SSRF fix) catastrophically deleted
11,500+ lines across 100+ files while fixing an unrelated IMAP issue.

Restored from d2217531^ (pre-bad-commit state):

Deleted files (fully restored):
- app/api/{automation,classification_rules,comments,sharing}.py
- app/middleware/upload_rate_limit.py
- app/tasks/{automation_tasks,classify_document}.py
- app/utils/{automation_hooks,classification_rules}.py
- docs/AppleAppStoreCompliance.md
- frontend/input.css, package.json, package-lock.json, tailwind.config.js
- frontend/static/js/{annotations,claim,comments,sharing}.js
- frontend/templates/{admin_connections,file_annotations,file_summary}.html
- tests/{test_api_files_comprehensive,test_auth_extended,test_sharing,
         test_comments,test_connections,test_imap_profiles,test_api_sessions,
         test_automation,test_classification_rules,test_api_advanced_filters,
         test_api_classification_rules,test_upload_rate_limit,test_api_dropbox,
         test_classify_document,test_comments_ui,test_upload_to_icloud,
         test_api_onedrive_comprehensive,test_frontend_build,test_sentry,
         test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py

Truncated files (content restored):
- app/{auth,config,main,models,celery_worker,database}.py
- app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive,
           integrations,local_auth,mobile,onedrive,pipelines,qr_auth,
           settings,url_upload}.py
- app/middleware/upload_rate_limit.py
- app/tasks/upload_to_nextcloud.py
- app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py
- app/views/{base,dropbox,files,google_drive,onedrive,settings}.py
- docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration,
        DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment,
        MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup,
        SocialLoginSetup,UserGuide}.md
- frontend/static/{js/upload.js,styles.css}
- frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback,
                      file_view,files,google_drive,onedrive,onedrive_callback,
                      signup}.html
- frontend/translations/en.json
- migrations/env.py
- tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings,
         test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks,
         test_setup_wizard,test_views_files_comprehensive}.py

Security fixes kept from post-d2217531 commits:
- app/utils/network.py: DNS SSRF fail-secure fix (06b0fced)
- app/utils/file_operations.py: path traversal fix (1018ea17)
- tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
This commit is contained in:
copilot-swe-agent[bot]
2026-03-23 23:52:39 +00:00
parent 11a49eb7fd
commit c7d3ec57c3
115 changed files with 23532 additions and 1043 deletions
+8
View File
@@ -9,9 +9,12 @@ from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router
from app.api.api_tokens import router as api_tokens_router
from app.api.audit_logs import router as audit_logs_router
from app.api.automation import router as automation_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
from app.api.classification_rules import router as classification_rules_router
from app.api.comments import router as comments_router
from app.api.compliance import router as compliance_router
from app.api.database import router as database_router
from app.api.diagnostic import router as diagnostic_router
@@ -43,6 +46,7 @@ from app.api.sessions import router as sessions_router
from app.api.settings import router as settings_router
from app.api.shared_links import public_router as shared_links_public_router
from app.api.shared_links import router as shared_links_router
from app.api.sharing import router as sharing_router
from app.api.similarity import router as similarity_router
from app.api.subscriptions import router as subscriptions_router
from app.api.system_reset import router as system_reset_router
@@ -104,3 +108,7 @@ router.include_router(qr_auth_router)
router.include_router(compliance_router)
router.include_router(system_reset_router)
router.include_router(translation_router)
router.include_router(classification_rules_router)
router.include_router(automation_router)
router.include_router(comments_router)
router.include_router(sharing_router)
+66 -9
View File
@@ -13,7 +13,7 @@ plaintext is returned exactly once at creation time.
import hashlib
import logging
import secrets
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -105,6 +105,7 @@ def _token_to_dict(t: ApiToken) -> dict[str, Any]:
"last_used_ip": t.last_used_ip,
"created_at": t.created_at,
"revoked_at": t.revoked_at,
"expires_at": t.expires_at,
}
@@ -117,6 +118,12 @@ class TokenCreate(BaseModel):
"""Schema for creating a new API token."""
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label for the token")
expires_in_days: int | None = Field(
default=None,
ge=1,
le=3650, # Maximum 10 years; keeps tokens from being effectively permanent while allowing long-lived CI/CD tokens.
description="Optional lifetime in days. If omitted the token never expires.",
)
class TokenResponse(BaseModel):
@@ -130,6 +137,7 @@ class TokenResponse(BaseModel):
last_used_ip: str | None
created_at: datetime | None
revoked_at: datetime | None
expires_at: datetime | None
model_config = {"from_attributes": True}
@@ -160,11 +168,16 @@ async def create_token(
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total
expires_at = None
if body.expires_in_days is not None:
expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days)
db_token = ApiToken(
owner_id=owner_id,
name=body.name,
token_hash=token_hash_value,
token_prefix=prefix,
expires_at=expires_at,
)
try:
db.add(db_token)
@@ -185,6 +198,7 @@ async def create_token(
"last_used_ip": db_token.last_used_ip,
"created_at": db_token.created_at,
"revoked_at": db_token.revoked_at,
"expires_at": db_token.expires_at,
"token": plaintext,
}
@@ -235,23 +249,24 @@ 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)
@@ -259,6 +274,48 @@ async def revoke_token(
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.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"}
@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)
+311
View File
@@ -0,0 +1,311 @@
"""API endpoints for Zapier / Make.com automation integration.
Provides a REST hooks subscription interface for outgoing triggers and
incoming action endpoints that external automation platforms can call.
Outgoing triggers:
External platforms subscribe to DocuElevate events via
``POST /api/automation/hooks/subscribe``. When a subscribed event
fires, DocuElevate POSTs a flat Zapier-compatible JSON payload to the
registered ``target_url``.
Incoming actions:
``POST /api/automation/actions/upload`` allows automation platforms to
push documents into DocuElevate for processing.
Authentication:
All endpoints require a valid API token via ``Authorization: Bearer``
header.
"""
import json
import logging
import os
import tempfile
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import AutomationHook
from app.utils.automation_hooks import SAMPLE_PAYLOADS
from app.utils.webhook import VALID_EVENTS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/automation", tags=["automation"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper require a valid API token (Bearer)
# ---------------------------------------------------------------------------
def _require_api_user(request: Request) -> dict:
"""Ensure the caller is authenticated via session or API token.
Raises:
HTTPException: 401 if not authenticated, 403 if automation hooks are disabled.
"""
if not settings.automation_hooks_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Automation hooks are disabled",
)
# Check for API-token user first (set by auth middleware)
user = getattr(request.state, "api_token_user", None)
if user:
return user
# Fall back to session user
user = request.session.get("user")
if user:
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required (Bearer token or session)",
)
AuthUser = Annotated[dict, Depends(_require_api_user)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class HookSubscribe(BaseModel):
"""Schema for subscribing to automation hook events."""
target_url: str = Field(..., min_length=1, max_length=2048, description="URL to POST event payloads to")
events: list[str] = Field(..., min_length=1, description="Event types to subscribe to")
secret: str | None = Field(default=None, max_length=512, description="Optional HMAC-SHA256 signing secret")
hook_type: str = Field(
default="generic",
max_length=50,
description="Platform identifier (zapier, make, generic)",
)
description: str | None = Field(default=None, max_length=500, description="Optional human-readable label")
class HookResponse(BaseModel):
"""Schema returned when listing or creating hooks."""
id: int
target_url: str
events: list[str]
is_active: bool
hook_type: str
description: str | None
has_secret: bool
model_config = {"from_attributes": True}
class ActionUploadResponse(BaseModel):
"""Response after an automation action uploads a document."""
status: str
filename: str
task_id: str | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_events(events: list[str]) -> None:
"""Raise 422 if any event name is not recognised."""
invalid = set(events) - VALID_EVENTS
if invalid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_EVENTS))}",
)
def _hook_to_response(hook: AutomationHook) -> dict[str, Any]:
"""Convert a DB model instance to a response dict."""
try:
events = json.loads(hook.events)
except (json.JSONDecodeError, TypeError):
events = []
return {
"id": hook.id,
"target_url": hook.target_url,
"events": events,
"is_active": hook.is_active,
"hook_type": hook.hook_type,
"description": hook.description,
"has_secret": hook.secret is not None and len(hook.secret) > 0,
}
# ---------------------------------------------------------------------------
# Outgoing triggers REST hooks subscription endpoints
# ---------------------------------------------------------------------------
@router.post(
"/hooks/subscribe",
status_code=status.HTTP_201_CREATED,
summary="Subscribe to automation events (REST hooks)",
)
def subscribe_hook(body: HookSubscribe, db: DbSession, user: AuthUser) -> dict[str, Any]:
"""Register a new automation hook subscription.
Zapier and Make.com call this endpoint to subscribe to DocuElevate
events. When an event fires, a flat JSON payload is POSTed to
``target_url``.
"""
_validate_events(body.events)
hook = AutomationHook(
target_url=body.target_url,
secret=body.secret,
events=json.dumps(sorted(body.events)),
is_active=True,
hook_type=body.hook_type or "generic",
description=body.description,
)
try:
db.add(hook)
db.commit()
db.refresh(hook)
except Exception:
db.rollback()
raise
logger.info("Automation hook %d created (type=%s) for events %s", hook.id, hook.hook_type, body.events)
return _hook_to_response(hook)
@router.delete(
"/hooks/{hook_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Unsubscribe an automation hook",
)
def unsubscribe_hook(hook_id: int, db: DbSession, user: AuthUser) -> None:
"""Remove an automation hook subscription.
Zapier calls this endpoint when a Zap is turned off or deleted.
"""
hook = db.query(AutomationHook).filter(AutomationHook.id == hook_id).first()
if not hook:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hook not found")
try:
db.delete(hook)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Automation hook %d deleted", hook_id)
@router.get("/hooks", summary="List automation hook subscriptions")
def list_hooks(db: DbSession, user: AuthUser) -> list[dict[str, Any]]:
"""Return all active automation hook subscriptions."""
hooks = db.query(AutomationHook).order_by(AutomationHook.id).all()
return [_hook_to_response(h) for h in hooks]
# ---------------------------------------------------------------------------
# Outgoing triggers sample data for Zapier field mapping
# ---------------------------------------------------------------------------
@router.get("/triggers/sample/{event}", summary="Get sample trigger data")
def get_trigger_sample(event: str, user: AuthUser) -> list[dict[str, Any]]:
"""Return sample payload data for the given event type.
Zapier uses this during Zap setup to discover available fields and
provide a mapping interface. The response is wrapped in an array
as Zapier expects.
"""
if event not in VALID_EVENTS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown event: {event}. Valid: {', '.join(sorted(VALID_EVENTS))}",
)
sample = SAMPLE_PAYLOADS.get(event, {"id": "evt_sample", "event": event, "timestamp": 0})
return [sample]
# ---------------------------------------------------------------------------
# Outgoing triggers list valid events
# ---------------------------------------------------------------------------
@router.get("/events", summary="List valid automation event types")
def list_events(user: AuthUser) -> list[str]:
"""Return the list of valid event types that automation hooks can subscribe to."""
return sorted(VALID_EVENTS)
# ---------------------------------------------------------------------------
# Incoming actions endpoints that Zapier / Make.com can call
# ---------------------------------------------------------------------------
@router.post("/actions/upload", summary="Upload a document (incoming action)")
def action_upload(
request: Request,
db: DbSession,
user: AuthUser,
file: UploadFile = File(...),
) -> dict[str, Any]:
"""Accept a document upload from an automation platform.
This endpoint allows Zapier or Make.com to push a document into
DocuElevate for processing. The file is saved to the work directory
and a background processing task is queued.
"""
if not file.filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
# Sanitise filename to prevent path traversal attacks
safe_filename = os.path.basename(file.filename)
if not safe_filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
owner_id = user.get("preferred_username") or user.get("email") or user.get("id", "automation")
workdir = settings.workdir or tempfile.gettempdir()
upload_dir = os.path.join(workdir, "uploads")
os.makedirs(upload_dir, exist_ok=True)
dest_path = os.path.join(upload_dir, safe_filename)
try:
contents = file.file.read()
with open(dest_path, "wb") as f:
f.write(contents)
except Exception as exc:
logger.error("Failed to save uploaded file: %s", exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save file")
# Queue background processing
task_id = None
try:
from app.tasks.process_document import process_document
result = process_document.delay(dest_path, owner_id)
task_id = result.id
logger.info("Automation upload queued: file=%s, task=%s, owner=%s", safe_filename, task_id, owner_id)
except Exception as exc:
logger.warning("Could not queue processing task (Celery may be unavailable): %s", exc)
return {
"status": "accepted",
"filename": safe_filename,
"task_id": task_id,
}
+325
View File
@@ -0,0 +1,325 @@
"""Classification Rules API endpoints.
Provides CRUD operations for managing custom document classification rules.
System-wide rules (``owner_id IS NULL``) can only be managed by admins.
"""
from __future__ import annotations
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
from app.models import ClassificationRuleModel
from app.utils.classification_rules import (
BUILTIN_CATEGORIES,
RULE_TYPE_CONTENT,
RULE_TYPE_FILENAME,
RULE_TYPE_METADATA,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/classification-rules", tags=["classification"])
DbSession = Annotated[Session, Depends(get_db)]
_VALID_RULE_TYPES = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_user_id(request: Request) -> str:
"""Extract the user identifier from the request session."""
user = getattr(request.state, "user", None)
if user and hasattr(user, "get"):
return user.get("sub") or user.get("email") or "anonymous"
return "anonymous"
def _is_admin(request: Request) -> bool:
"""Check whether the current user is an admin."""
user = getattr(request.state, "user", None)
if user and hasattr(user, "get"):
groups = user.get("groups", [])
return "admin" in groups or "Admin" in groups
return False
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class RuleCreate(BaseModel):
"""Schema for creating a classification rule."""
name: str = Field(..., min_length=1, max_length=255)
category: str = Field(..., min_length=1, max_length=100)
rule_type: str = Field(..., description="One of: filename_pattern, content_keyword, metadata_match")
pattern: str = Field(..., min_length=1, max_length=1000)
priority: int = Field(default=0, ge=0, le=1000)
case_sensitive: bool = False
enabled: bool = True
class RuleUpdate(BaseModel):
"""Schema for updating a classification rule."""
name: str | None = Field(default=None, min_length=1, max_length=255)
category: str | None = Field(default=None, min_length=1, max_length=100)
rule_type: str | None = Field(default=None)
pattern: str | None = Field(default=None, min_length=1, max_length=1000)
priority: int | None = Field(default=None, ge=0, le=1000)
case_sensitive: bool | None = None
enabled: bool | None = None
class RuleResponse(BaseModel):
"""Schema for a classification rule response."""
id: int
owner_id: str | None
name: str
category: str
rule_type: str
pattern: str
priority: int
case_sensitive: bool
enabled: bool
model_config = {"from_attributes": True}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/categories")
@require_login
async def list_categories(request: Request) -> dict[str, str]:
"""Return all built-in classification categories.
Custom categories created via rules are not included here; they are
discovered dynamically when rules are evaluated.
"""
return BUILTIN_CATEGORIES
@router.get("/rule-types")
@require_login
async def list_rule_types(request: Request) -> list[dict[str, str]]:
"""Return the supported rule types with descriptions."""
return [
{
"type": RULE_TYPE_FILENAME,
"label": "Filename Pattern",
"description": "Regex pattern matched against the original filename.",
},
{
"type": RULE_TYPE_CONTENT,
"label": "Content Keyword",
"description": "Pipe-separated keywords matched against the OCR text.",
},
{
"type": RULE_TYPE_METADATA,
"label": "Metadata Match",
"description": "field=value pattern matched against existing AI metadata.",
},
]
@router.get("/")
@require_login
async def list_rules(request: Request, db: DbSession) -> list[dict[str, Any]]:
"""List classification rules visible to the current user.
Returns both system rules (``owner_id IS NULL``) and the user's own rules.
"""
user_id = _get_user_id(request)
rules = (
db.query(ClassificationRuleModel)
.filter((ClassificationRuleModel.owner_id.is_(None)) | (ClassificationRuleModel.owner_id == user_id))
.order_by(ClassificationRuleModel.priority.desc(), ClassificationRuleModel.id)
.all()
)
return [
{
"id": r.id,
"owner_id": r.owner_id,
"name": r.name,
"category": r.category,
"rule_type": r.rule_type,
"pattern": r.pattern,
"priority": r.priority,
"case_sensitive": r.case_sensitive,
"enabled": r.enabled,
}
for r in rules
]
@router.post("/", status_code=status.HTTP_201_CREATED)
@require_login
async def create_rule(request: Request, body: RuleCreate, db: DbSession) -> dict[str, Any]:
"""Create a new custom classification rule.
The rule is owned by the current user. Admins may create system-wide
rules by setting ``owner_id`` to ``null`` (not yet exposed).
"""
if body.rule_type not in _VALID_RULE_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid rule_type. Must be one of: {', '.join(sorted(_VALID_RULE_TYPES))}",
)
user_id = _get_user_id(request)
# Check for duplicate name within the user's scope
existing = (
db.query(ClassificationRuleModel)
.filter(ClassificationRuleModel.owner_id == user_id, ClassificationRuleModel.name == body.name)
.first()
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A rule named '{body.name}' already exists.",
)
rule = ClassificationRuleModel(
owner_id=user_id,
name=body.name,
category=body.category,
rule_type=body.rule_type,
pattern=body.pattern,
priority=body.priority,
case_sensitive=body.case_sensitive,
enabled=body.enabled,
)
try:
db.add(rule)
db.commit()
db.refresh(rule)
except Exception:
db.rollback()
raise
logger.info("Classification rule created: id=%s, user=%s", rule.id, user_id)
return {
"id": rule.id,
"owner_id": rule.owner_id,
"name": rule.name,
"category": rule.category,
"rule_type": rule.rule_type,
"pattern": rule.pattern,
"priority": rule.priority,
"case_sensitive": rule.case_sensitive,
"enabled": rule.enabled,
}
@router.get("/{rule_id}")
@require_login
async def get_rule(request: Request, rule_id: int, db: DbSession) -> dict[str, Any]:
"""Get a single classification rule by ID."""
user_id = _get_user_id(request)
rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first()
if rule is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
# Users can see system rules and their own rules
if rule.owner_id is not None and rule.owner_id != user_id and not _is_admin(request):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
return {
"id": rule.id,
"owner_id": rule.owner_id,
"name": rule.name,
"category": rule.category,
"rule_type": rule.rule_type,
"pattern": rule.pattern,
"priority": rule.priority,
"case_sensitive": rule.case_sensitive,
"enabled": rule.enabled,
}
@router.put("/{rule_id}")
@require_login
async def update_rule(request: Request, rule_id: int, body: RuleUpdate, db: DbSession) -> dict[str, Any]:
"""Update an existing classification rule.
Users can only update their own rules. Admins can update any rule.
"""
user_id = _get_user_id(request)
rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first()
if rule is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
if rule.owner_id != user_id and not _is_admin(request):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify this rule")
if body.rule_type is not None and body.rule_type not in _VALID_RULE_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid rule_type. Must be one of: {', '.join(sorted(_VALID_RULE_TYPES))}",
)
update_data = body.model_dump(exclude_unset=True)
for field_name, value in update_data.items():
setattr(rule, field_name, value)
try:
db.commit()
db.refresh(rule)
except Exception:
db.rollback()
raise
logger.info("Classification rule updated: id=%s, user=%s", rule.id, user_id)
return {
"id": rule.id,
"owner_id": rule.owner_id,
"name": rule.name,
"category": rule.category,
"rule_type": rule.rule_type,
"pattern": rule.pattern,
"priority": rule.priority,
"case_sensitive": rule.case_sensitive,
"enabled": rule.enabled,
}
@router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
async def delete_rule(request: Request, rule_id: int, db: DbSession) -> None:
"""Delete a classification rule.
Users can only delete their own rules. Admins can delete any rule.
"""
user_id = _get_user_id(request)
rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first()
if rule is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
if rule.owner_id != user_id and not _is_admin(request):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete this rule")
try:
db.delete(rule)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Classification rule deleted: id=%s, user=%s", rule_id, user_id)
+751
View File
@@ -0,0 +1,751 @@
"""Document comments and annotations API endpoints.
Provides CRUD operations for threaded comments on documents,
text annotations on PDF pages, and a list of mentionable users
for the @mention feature.
"""
import json
import logging
import re
from typing import Annotated, Any
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import get_current_user_id, require_login
from app.database import get_db
from app.models import (
FILE_SHARE_ROLE_VIEWER,
DocumentAnnotation,
DocumentComment,
FileRecord,
FileShare,
UserProfile,
)
from app.utils.user_scope import get_current_owner_id, has_file_role
logger = logging.getLogger(__name__)
router = APIRouter(tags=["comments"])
DbSession = Annotated[Session, Depends(get_db)]
# Constraints
MAX_COMMENT_BODY_LENGTH = 10_000
MAX_ANNOTATION_CONTENT_LENGTH = 5_000
# Allowed annotation types
ALLOWED_ANNOTATION_TYPES = frozenset({"note", "highlight", "underline", "strikethrough"})
# Simple pattern for @mentions matches @username tokens inside comment body
_MENTION_PATTERN = re.compile(r"@([\w.\-]+)")
def _extract_mentions(body: str) -> list[str]:
"""Extract unique @mentioned usernames from a comment body.
Args:
body: The raw comment text.
Returns:
A deduplicated list of mentioned usernames (without the ``@`` prefix).
"""
return list(dict.fromkeys(_MENTION_PATTERN.findall(body)))
def _serialize_comment(c: DocumentComment) -> dict[str, Any]:
"""Serialize a DocumentComment to a JSON-friendly dict.
Args:
c: The comment model instance.
Returns:
A dictionary representation of the comment.
"""
mentions: list[str] = []
if c.mentions:
try:
mentions = json.loads(c.mentions)
except (json.JSONDecodeError, TypeError):
pass
return {
"id": c.id,
"file_id": c.file_id,
"user_id": c.user_id,
"parent_id": c.parent_id,
"body": c.body,
"mentions": mentions,
"is_resolved": c.is_resolved,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
def _serialize_annotation(a: DocumentAnnotation) -> dict[str, Any]:
"""Serialize a DocumentAnnotation to a JSON-friendly dict.
Args:
a: The annotation model instance.
Returns:
A dictionary representation of the annotation.
"""
return {
"id": a.id,
"file_id": a.file_id,
"user_id": a.user_id,
"page": a.page,
"x": a.x,
"y": a.y,
"width": a.width,
"height": a.height,
"content": a.content,
"annotation_type": a.annotation_type,
"color": a.color,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
def _build_thread_tree(comments: list[DocumentComment]) -> list[dict[str, Any]]:
"""Organize a flat list of comments into a threaded tree structure.
Top-level comments (``parent_id is None``) appear as root nodes.
Replies are nested inside their parent's ``replies`` list.
Args:
comments: All comments for a given document, ordered by ``created_at``.
Returns:
A list of root-level comment dicts, each with a ``replies`` key.
"""
by_id: dict[int, dict[str, Any]] = {}
roots: list[dict[str, Any]] = []
for c in comments:
node = _serialize_comment(c)
node["replies"] = []
by_id[c.id] = node
for c in comments:
node = by_id[c.id]
if c.parent_id and c.parent_id in by_id:
by_id[c.parent_id]["replies"].append(node)
else:
roots.append(node)
return roots
# ---------------------------------------------------------------------------
# Comments endpoints
# ---------------------------------------------------------------------------
@router.get("/files/{file_id}/comments")
@require_login
def list_comments(request: Request, file_id: int, db: DbSession):
"""List all comments for a document, organized into threads.
Returns a threaded tree where top-level comments contain nested
``replies``. Requires at least viewer access.
Path Parameters:
file_id: The ID of the document.
Returns:
A dict with ``file_id``, ``comments`` (threaded), and ``total``.
"""
user_id = get_current_owner_id(request)
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
comments = (
db.query(DocumentComment).filter(DocumentComment.file_id == file_id).order_by(DocumentComment.created_at).all()
)
return {
"file_id": file_id,
"comments": _build_thread_tree(comments),
"total": len(comments),
}
@router.post("/files/{file_id}/comments", status_code=status.HTTP_201_CREATED)
@require_login
def create_comment(
request: Request,
file_id: int,
db: DbSession,
body: str = Body(..., embed=True),
parent_id: int | None = Body(None, embed=True),
):
"""Create a new comment on a document.
Automatically extracts @mentions from the comment body and stores
them for later notification or UI highlighting. When multi-user
mode is enabled, any mentioned user that does not already have
access to the document is automatically granted ``viewer`` access by
the file owner so they can read the file and continue the discussion.
Path Parameters:
file_id: The ID of the document to comment on.
Request body (JSON):
body: Comment text (required, max 10 000 characters).
parent_id: ID of the parent comment for threaded replies (optional).
Returns:
The created comment object.
"""
user_id = get_current_user_id(request)
owner_id = get_current_owner_id(request)
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not isinstance(body, str) or not body.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="body is required and must be non-empty",
)
body = body.strip()
if len(body) > MAX_COMMENT_BODY_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
)
if parent_id is not None:
parent = (
db.query(DocumentComment)
.filter(DocumentComment.id == parent_id, DocumentComment.file_id == file_id)
.first()
)
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent comment not found",
)
mentions = _extract_mentions(body)
comment = DocumentComment(
file_id=file_id,
user_id=user_id,
parent_id=parent_id,
body=body,
mentions=json.dumps(mentions) if mentions else None,
)
try:
db.add(comment)
db.flush() # write comment so we can get its id before committing
# Auto-share the file with mentioned users that don't have access yet.
# Only do this in multi-user mode and only when the file has an owner
# (unowned files are already visible to all authenticated users).
if mentions and file_record.owner_id is not None:
from app.config import settings as _settings
if _settings.multi_user_enabled:
for mentioned_user in mentions:
# Skip the file owner (already has full access) and the commenter
# themselves (they already have access to be posting a comment).
if mentioned_user in {file_record.owner_id, owner_id}:
continue
existing_share = (
db.query(FileShare)
.filter(
FileShare.file_id == file_id,
FileShare.shared_with_user_id == mentioned_user,
)
.first()
)
if not existing_share:
auto_share = FileShare(
file_id=file_id,
owner_id=file_record.owner_id,
shared_with_user_id=mentioned_user,
role=FILE_SHARE_ROLE_VIEWER,
)
db.add(auto_share)
logger.info(
"Auto-shared file_id=%s with mentioned user=%s as viewer",
file_id,
mentioned_user,
)
db.commit()
db.refresh(comment)
except HTTPException:
raise
except Exception:
db.rollback()
logger.exception("Failed to create comment on file_id=%s", file_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create comment",
)
logger.info("Comment created: id=%s, file_id=%s, user=%s", comment.id, file_id, user_id)
return _serialize_comment(comment)
@router.put("/files/{file_id}/comments/{comment_id}")
@require_login
def update_comment(
request: Request,
file_id: int,
comment_id: int,
db: DbSession,
body: str = Body(..., embed=True),
):
"""Update the body of an existing comment.
Only the comment author may update the comment. Mentions are
re-extracted from the updated body.
Path Parameters:
file_id: The ID of the document.
comment_id: The ID of the comment to update.
Request body (JSON):
body: New comment text (required).
Returns:
The updated comment object.
"""
user_id = get_current_user_id(request)
comment = (
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
)
if not comment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
if comment.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own comments")
if not isinstance(body, str) or not body.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="body is required and must be non-empty",
)
body = body.strip()
if len(body) > MAX_COMMENT_BODY_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
)
mentions = _extract_mentions(body)
comment.body = body
comment.mentions = json.dumps(mentions) if mentions else None
try:
db.commit()
db.refresh(comment)
except Exception:
db.rollback()
logger.exception("Failed to update comment id=%s", comment_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update comment",
)
logger.info("Comment updated: id=%s, user=%s", comment_id, user_id)
return _serialize_comment(comment)
@router.delete("/files/{file_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
def delete_comment(request: Request, file_id: int, comment_id: int, db: DbSession):
"""Delete a comment.
Only the comment author may delete the comment. Replies to the
deleted comment are **not** removed — they become orphaned root
comments so that conversation context is preserved.
Path Parameters:
file_id: The ID of the document.
comment_id: The ID of the comment to delete.
"""
user_id = get_current_user_id(request)
comment = (
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
)
if not comment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
if comment.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own comments")
try:
db.delete(comment)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to delete comment id=%s", comment_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete comment",
)
logger.info("Comment deleted: id=%s, user=%s", comment_id, user_id)
@router.patch("/files/{file_id}/comments/{comment_id}/resolve")
@require_login
def resolve_comment(
request: Request,
file_id: int,
comment_id: int,
db: DbSession,
is_resolved: bool = Body(..., embed=True),
):
"""Mark a top-level comment thread as resolved or unresolved.
Path Parameters:
file_id: The ID of the document.
comment_id: The ID of the comment to resolve / unresolve.
Request body (JSON):
is_resolved: ``true`` to resolve, ``false`` to unresolve.
Returns:
The updated comment object.
"""
comment = (
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
)
if not comment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
comment.is_resolved = is_resolved
try:
db.commit()
db.refresh(comment)
except Exception:
db.rollback()
logger.exception("Failed to resolve comment id=%s", comment_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update comment",
)
logger.info("Comment %s: id=%s", "resolved" if is_resolved else "unresolved", comment_id)
return _serialize_comment(comment)
# ---------------------------------------------------------------------------
# Annotations endpoints
# ---------------------------------------------------------------------------
@router.get("/files/{file_id}/annotations")
@require_login
def list_annotations(request: Request, file_id: int, db: DbSession):
"""List all annotations for a document.
Requires at least viewer access.
Path Parameters:
file_id: The ID of the document.
Returns:
A dict with ``file_id``, ``annotations``, and ``total``.
"""
user_id = get_current_owner_id(request)
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
annotations = (
db.query(DocumentAnnotation)
.filter(DocumentAnnotation.file_id == file_id)
.order_by(DocumentAnnotation.page, DocumentAnnotation.created_at)
.all()
)
return {
"file_id": file_id,
"annotations": [_serialize_annotation(a) for a in annotations],
"total": len(annotations),
}
@router.post("/files/{file_id}/annotations", status_code=status.HTTP_201_CREATED)
@require_login
def create_annotation(
request: Request,
file_id: int,
db: DbSession,
page: int = Body(..., embed=True),
x: float = Body(..., embed=True),
y: float = Body(..., embed=True),
content: str = Body(..., embed=True),
width: float = Body(0, embed=True),
height: float = Body(0, embed=True),
annotation_type: str = Body("note", embed=True),
color: str | None = Body(None, embed=True),
):
"""Create a new annotation on a PDF page.
Path Parameters:
file_id: The ID of the document.
Request body (JSON):
page: Page number (1-based, required).
x: Horizontal position on the page (required).
y: Vertical position on the page (required).
content: Annotation text (required, max 5 000 characters).
width: Width of the annotation bounding box (default 0).
height: Height of the annotation bounding box (default 0).
annotation_type: One of ``note``, ``highlight``, ``underline``,
``strikethrough`` (default ``note``).
color: Optional CSS colour string (e.g. ``#ff0000``).
Returns:
The created annotation object.
"""
user_id = get_current_user_id(request)
owner_id = get_current_owner_id(request)
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not isinstance(content, str) or not content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="content is required and must be non-empty",
)
content = content.strip()
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
)
if page < 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="page must be >= 1",
)
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
)
annotation = DocumentAnnotation(
file_id=file_id,
user_id=user_id,
page=page,
x=x,
y=y,
width=width,
height=height,
content=content,
annotation_type=annotation_type,
color=color,
)
try:
db.add(annotation)
db.commit()
db.refresh(annotation)
except Exception:
db.rollback()
logger.exception("Failed to create annotation on file_id=%s", file_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create annotation",
)
logger.info("Annotation created: id=%s, file_id=%s, user=%s", annotation.id, file_id, user_id)
return _serialize_annotation(annotation)
@router.put("/files/{file_id}/annotations/{annotation_id}")
@require_login
def update_annotation(
request: Request,
file_id: int,
annotation_id: int,
db: DbSession,
content: str | None = Body(None, embed=True),
x: float | None = Body(None, embed=True),
y: float | None = Body(None, embed=True),
width: float | None = Body(None, embed=True),
height: float | None = Body(None, embed=True),
annotation_type: str | None = Body(None, embed=True),
color: str | None = Body(None, embed=True),
):
"""Update an existing annotation.
Only the annotation author may update the annotation.
Path Parameters:
file_id: The ID of the document.
annotation_id: The ID of the annotation to update.
Request body (JSON):
Any subset of ``content``, ``x``, ``y``, ``width``, ``height``,
``annotation_type``, and ``color``.
Returns:
The updated annotation object.
"""
user_id = get_current_user_id(request)
annotation = (
db.query(DocumentAnnotation)
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
.first()
)
if not annotation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
if annotation.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own annotations")
if content is not None:
content = content.strip() if isinstance(content, str) else ""
if not content:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="content must be non-empty",
)
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
)
annotation.content = content
if x is not None:
annotation.x = x
if y is not None:
annotation.y = y
if width is not None:
annotation.width = width
if height is not None:
annotation.height = height
if annotation_type is not None:
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
)
annotation.annotation_type = annotation_type
if color is not None:
annotation.color = color
try:
db.commit()
db.refresh(annotation)
except Exception:
db.rollback()
logger.exception("Failed to update annotation id=%s", annotation_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update annotation",
)
logger.info("Annotation updated: id=%s, user=%s", annotation_id, user_id)
return _serialize_annotation(annotation)
@router.delete("/files/{file_id}/annotations/{annotation_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
def delete_annotation(request: Request, file_id: int, annotation_id: int, db: DbSession):
"""Delete an annotation.
Only the annotation author may delete the annotation.
Path Parameters:
file_id: The ID of the document.
annotation_id: The ID of the annotation to delete.
"""
user_id = get_current_user_id(request)
annotation = (
db.query(DocumentAnnotation)
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
.first()
)
if not annotation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
if annotation.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own annotations")
try:
db.delete(annotation)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to delete annotation id=%s", annotation_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete annotation",
)
logger.info("Annotation deleted: id=%s, user=%s", annotation_id, user_id)
# ---------------------------------------------------------------------------
# Mentionable users endpoint
# ---------------------------------------------------------------------------
@router.get("/users/mentionable")
@require_login
def list_mentionable_users(request: Request, db: DbSession):
"""List users that can be @mentioned in comments.
Returns all user profiles that are not blocked, sorted by
``display_name``.
Returns:
A list of ``{user_id, display_name}`` objects.
"""
profiles = db.query(UserProfile).filter(UserProfile.is_blocked.is_(False)).order_by(UserProfile.display_name).all()
return [
{
"user_id": p.user_id,
"display_name": p.display_name or p.user_id,
}
for p in profiles
]
+61
View File
@@ -21,6 +21,67 @@ _DEFAULT_REDIS_URL = "redis://localhost:6379/0"
router = APIRouter()
# ---------------------------------------------------------------------------
# Unauthenticated probe endpoints for Kubernetes liveness / readiness checks.
# These intentionally skip authentication so that kubelet can reach them
# without credentials. They live under /diagnostic/healthz/* so that the
# existing authenticated /diagnostic/health endpoint is unaffected.
# ---------------------------------------------------------------------------
@router.get("/diagnostic/healthz/live")
async def liveness_probe() -> JSONResponse:
"""Lightweight liveness probe for Kubernetes.
Returns **200 OK** as long as the process is running. Kubernetes uses
this to decide whether to *restart* the container — it should therefore
be as cheap as possible and **never** check external dependencies.
**Authentication:** None (designed for kubelet probes).
"""
return JSONResponse(content={"status": "ok"}, status_code=200)
@router.get("/diagnostic/healthz/ready")
async def readiness_probe() -> JSONResponse:
"""Readiness probe for Kubernetes.
Verifies that the application can serve traffic by checking the database
and Redis. Kubernetes uses this to decide whether to *route traffic* to
the pod.
Returns **200 OK** when all critical subsystems are reachable, or
**503 Service Unavailable** when the database is down.
**Authentication:** None (designed for kubelet probes).
"""
checks: dict[str, dict[str, str]] = {}
db_ok = False
# ── Database check ─────────────────────────────────────────────────
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
checks["database"] = {"status": "ok"}
db_ok = True
except Exception as exc:
logger.warning("Readiness probe: database check failed: %s", exc)
checks["database"] = {"status": "error", "detail": str(exc)}
# ── Redis check ────────────────────────────────────────────────────
try:
redis_url = settings.redis_url or _DEFAULT_REDIS_URL
r = redis_lib.from_url(redis_url, socket_connect_timeout=2, socket_timeout=2)
r.ping()
checks["redis"] = {"status": "ok"}
except Exception as exc:
logger.warning("Readiness probe: Redis check failed: %s", exc)
checks["redis"] = {"status": "error", "detail": str(exc)}
http_status = 503 if not db_ok else 200
overall = "ready" if db_ok else "not_ready"
return JSONResponse(content={"status": overall, "checks": checks}, status_code=http_status)
@router.get("/diagnostic/health")
@require_login
+201 -16
View File
@@ -5,16 +5,18 @@ Dropbox API endpoints
import logging
import os
from typing import Annotated, Optional
from urllib.parse import quote
import httpx
import requests
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import AUTH_ENABLED, require_login
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db, update_env_file
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
@@ -23,21 +25,91 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request) -> dict:
"""Dependency to ensure the current user is an admin.
def _build_dropbox_redirect_uri(request: Request) -> str:
"""Build the Dropbox OAuth callback redirect URI.
When AUTH_ENABLED=False (single-user/development mode), admin checks are
skipped because there is no authentication at all.
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 not AUTH_ENABLED:
return {}
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
if settings.public_base_url:
return settings.public_base_url.rstrip("/") + "/dropbox-callback"
return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback"
AdminUser = Annotated[dict, Depends(_require_admin)]
@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")
@@ -227,10 +299,95 @@ async def test_dropbox_token(request: Request):
return {"status": "error", "message": f"Connection error: {str(e)}"}
@router.post("/dropbox/list-folders")
@require_login
async def list_dropbox_folders(
request: Request,
access_token: Annotated[str, Form(...)],
path: Annotated[str, Form()] = "",
):
"""
List folders in a Dropbox account for the directory selector.
Accepts an OAuth access token (short-lived) and a path to list.
Returns a flat list of folder entries under the given path.
"""
try:
# Normalize path: Dropbox API uses "" for root, otherwise "/path"
folder_path = path.strip()
if folder_path == "/":
folder_path = ""
elif folder_path and not folder_path.startswith("/"):
folder_path = f"/{folder_path}"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {
"path": folder_path,
"recursive": False,
"include_deleted": False,
"include_has_explicit_shared_members": False,
"include_mounted_folders": True,
}
response = requests.post(
"https://api.dropboxapi.com/2/files/list_folder",
headers=headers,
json=payload,
timeout=settings.http_request_timeout,
)
if response.status_code == 401:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token is invalid or expired. Please re-authorize.",
)
if response.status_code != 200:
logger.error(f"Dropbox list_folder failed: {response.status_code} {response.text}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to list Dropbox folders: {response.text}",
)
data = response.json()
folders = []
for entry in data.get("entries", []):
if entry.get(".tag") == "folder":
folders.append(
{
"name": entry["name"],
"path": entry["path_display"],
"id": entry.get("id", ""),
}
)
# Sort folders alphabetically
folders.sort(key=lambda f: f["name"].lower())
return {
"folders": folders,
"path": folder_path or "/",
"has_more": data.get("has_more", False),
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Error listing Dropbox folders: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list folders: {str(e)}",
)
@router.post("/dropbox/save-settings")
@require_login
async def save_dropbox_settings(
request: Request,
_admin: AdminUser,
refresh_token: Annotated[str, Form(...)],
app_key: Annotated[Optional[str], Form()] = None,
app_secret: Annotated[Optional[str], Form()] = None,
@@ -269,6 +426,14 @@ async def save_dropbox_settings(
# Best-effort .env file write
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if not os.path.exists(env_path):
logger.warning(f".env file not found at {env_path}, skipping file write")
else:
logger.info(f"Updating Dropbox settings in {env_path}")
with open(env_path, "r") as f:
env_lines = f.readlines()
dropbox_settings = {"DROPBOX_REFRESH_TOKEN": refresh_token}
if app_key:
dropbox_settings["DROPBOX_APP_KEY"] = app_key
@@ -277,8 +442,28 @@ async def save_dropbox_settings(
if folder_path:
dropbox_settings["DROPBOX_FOLDER"] = folder_path
if not update_env_file(env_path, dropbox_settings):
logger.info("Continuing with in-memory update despite .env file update failure or skip")
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in dropbox_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
for key, value in dropbox_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated Dropbox settings in .env file")
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
+64 -14
View File
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.middleware.upload_rate_limit import require_upload_rate_limit
from app.models import FileProcessingStep, FileRecord, ProcessingLog
from app.tasks.convert_to_pdf import convert_to_pdf
from app.tasks.process_document import process_document
@@ -29,7 +30,7 @@ from app.utils.file_queries import apply_status_filter
from app.utils.file_status import get_files_processing_status
from app.utils.filename_utils import sanitize_filename
from app.utils.input_validation import validate_search_query, validate_sort_field, validate_sort_order
from app.utils.user_scope import apply_owner_filter, get_current_owner_id
from app.utils.user_scope import apply_owner_filter, get_current_owner_id, get_file_role
# Set up logging
logger = logging.getLogger(__name__)
@@ -299,6 +300,7 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
"""
Delete a file record from the database.
This only removes the database entry, not the actual file.
Only the file owner (or an admin) may delete a document.
"""
# Check if file deletion is allowed
if not settings.allow_file_delete:
@@ -313,6 +315,18 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
if not file_record:
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
# Enforce owner-only deletion in multi-user mode
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
if not is_admin:
owner_id = get_current_owner_id(request)
role = get_file_role(file_record, owner_id, db)
if role != "owner":
raise HTTPException(
status_code=403,
detail="Only the file owner can delete this document",
)
# Log the deletion
logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}")
@@ -339,6 +353,7 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
"""
Delete multiple file records from the database.
This only removes the database entries, not the actual files.
Only the file owner (or an admin) may delete each document.
"""
# Check if file deletion is allowed
if not settings.allow_file_delete:
@@ -353,6 +368,18 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
# Enforce owner-only deletion in multi-user mode
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
if not is_admin:
owner_id = get_current_owner_id(request)
non_owner_ids = [f.id for f in file_records if get_file_role(f, owner_id, db) != "owner"]
if non_owner_ids:
raise HTTPException(
status_code=403,
detail=f"You can only delete files you own. Not owner of file IDs: {non_owner_ids}",
)
deleted_count = len(file_records)
deleted_ids = [f.id for f in file_records]
@@ -1267,7 +1294,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
@@ -1286,8 +1318,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:
@@ -1298,7 +1330,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
@@ -1384,6 +1421,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()
@@ -1447,6 +1503,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)
@@ -1460,20 +1518,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
# ---------------------------------------------------------------------------
+43 -17
View File
@@ -14,7 +14,7 @@ from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db, update_env_file
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
@@ -23,17 +23,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request) -> dict:
"""Dependency to ensure the current user is an admin."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
@router.post("/google-drive/exchange-token")
@require_login
async def exchange_google_drive_token(
@@ -373,9 +362,9 @@ def format_time_remaining(time_delta):
@router.post("/google-drive/save-settings")
@require_login
async def save_google_drive_settings(
request: Request,
_admin: AdminUser,
refresh_token: Annotated[str, Form(...)],
client_id: Annotated[Optional[str], Form()] = None,
client_secret: Annotated[Optional[str], Form()] = None,
@@ -415,9 +404,46 @@ async def save_google_drive_settings(
drive_settings["GOOGLE_DRIVE_FOLDER_ID"] = folder_id
# Try to update the .env file, but don't fail if it doesn't exist (for Docker containers)
env_write_success = update_env_file(env_path, drive_settings)
if not env_write_success:
logger.info("Continuing with in-memory update despite .env file update failure or skip")
if os.path.exists(env_path):
try:
logger.info(f"Updating Google Drive settings in {env_path}")
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in drive_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
# Uncomment if commented out - check the original stripped line
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
# Add any settings that weren't updated (they weren't in the file)
for key, value in drive_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Write the updated .env file
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated Google Drive settings in .env file")
except Exception as e:
logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update")
else:
logger.warning(
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
)
# Update the settings in memory (this always happens)
if refresh_token:
@@ -455,7 +481,7 @@ async def save_google_drive_settings(
return {
"status": "success",
"message": "Google Drive settings have been saved",
"in_memory_only": not env_write_success,
"in_memory_only": not os.path.exists(env_path),
}
except Exception as e:
+64 -10
View File
@@ -32,6 +32,21 @@ from app.utils.encryption import decrypt_value, encrypt_value
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
# Optional Dropbox SDK — imported at module level so tests can patch it cleanly.
try:
import dropbox as dbx_lib
from dropbox.exceptions import AuthError as _DropboxAuthError
from dropbox.exceptions import BadInputError as _DropboxBadInputError
except ImportError: # pragma: no cover
dbx_lib = None # type: ignore[assignment]
class _DropboxAuthError(Exception): # type: ignore[no-redef]
"""Stub — only used when the dropbox package is missing."""
class _DropboxBadInputError(Exception): # type: ignore[no-redef]
"""Stub — only used when the dropbox package is missing."""
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/integrations", tags=["integrations"])
@@ -550,9 +565,50 @@ 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
import httpx
cfg = config or {}
creds = credentials or {}
@@ -579,23 +635,21 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
try:
import base64
auth = (username, password) if username and password else None
headers = {"Depth": "0"}
req = urllib.request.Request(url, method="PROPFIND") # noqa: S310
if username and password:
token = base64.b64encode(f"{username}:{password}".encode()).decode()
req.add_header("Authorization", f"Basic {token}")
req.add_header("Depth", "0")
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
if resp.status < 400:
# Use httpx for secure connection testing, avoiding urllib vulnerabilities
resp = httpx.request("PROPFIND", url, auth=auth, headers=headers, timeout=10.0, follow_redirects=False)
if resp.status_code < 400:
return {"success": True, "message": "WebDAV connection successful"}
return {"success": False, "message": f"WebDAV returned HTTP {resp.status}"}
return {"success": False, "message": f"WebDAV returned HTTP {resp.status_code}"}
except Exception as exc: # noqa: BLE001
logger.warning("WebDAV connection error for %s: %s", hostname, exc)
return {"success": False, "message": "WebDAV connection failed — check URL and credentials"}
_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,
+21 -6
View File
@@ -120,6 +120,7 @@ class WhoAmIResponse(BaseModel):
email: str | None
avatar_url: str | None
is_admin: bool
preferred_language: str | None
# ---------------------------------------------------------------------------
@@ -273,31 +274,44 @@ async def list_devices(
return [_device_to_response(d) for d in devices]
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/devices/{device_id}", status_code=status.HTTP_200_OK)
@require_login
async def deactivate_device(
request: Request,
device_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> None:
"""Deactivate a push-notification device registration.
) -> dict[str, str]:
"""Deactivate or permanently delete a push-notification device registration.
The device record is kept for audit purposes but will no longer receive
push notifications.
* **Active device** soft-deactivated: the record is kept for audit
purposes but will no longer receive push notifications.
* **Already-inactive device** hard-deleted: the record is permanently
removed from the database.
"""
device = db.get(MobileDevice, device_id)
if not device or device.owner_id != owner_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
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 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,
}
+120 -58
View File
@@ -3,20 +3,20 @@ OneDrive API endpoints
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Annotated, Optional
import httpx
import requests
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import AUTH_ENABLED, require_login
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.env_utils import update_env_file as _update_env_file_auto
from app.utils.env_utils import update_env_file
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db, update_env_file
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
@@ -25,23 +25,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request) -> dict:
"""Dependency to ensure the current user is an admin.
When AUTH_ENABLED=False (single-user/development mode), admin checks are
skipped because there is no authentication at all.
"""
if not AUTH_ENABLED:
return {}
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
@router.post("/onedrive/exchange-token")
@require_login
async def exchange_onedrive_token(
@@ -74,6 +57,7 @@ async def exchange_onedrive_token(
# Return just what's needed by the frontend
return {
"refresh_token": token_data["refresh_token"],
"access_token": token_data.get("access_token", ""),
"expires_in": token_data.get("expires_in", 3600),
}
@@ -134,7 +118,7 @@ async def test_onedrive_token(request: Request):
settings.onedrive_refresh_token = new_refresh_token
# Also try to update .env file if it exists
_update_env_file_auto({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token})
update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token})
# Persist the rotated refresh token to the database
try:
@@ -201,6 +185,102 @@ async def test_onedrive_token(request: Request):
return {"status": "error", "message": f"Connection error: {str(e)}"}
@router.post("/onedrive/list-folders")
@require_login
async def list_onedrive_folders(
request: Request,
access_token: Annotated[str, Form(...)],
path: Annotated[str, Form()] = "",
):
"""
List folders in a OneDrive account for the directory selector.
Accepts an OAuth access token (short-lived) and a path to list.
Returns a flat list of folder entries under the given path.
"""
try:
folder_path = path.strip().strip("/")
headers = {
"Authorization": f"Bearer {access_token}",
}
# Build the Graph API URL for listing children
if not folder_path or folder_path == "root":
url = "https://graph.microsoft.com/v1.0/me/drive/root/children"
else:
url = f"https://graph.microsoft.com/v1.0/me/drive/root:/{folder_path}:/children"
# Only request folders and minimal fields
params = {
"$filter": "folder ne null",
"$select": "name,id,parentReference,folder",
"$top": "200",
}
response = requests.get(
url,
headers=headers,
params=params,
timeout=settings.http_request_timeout,
)
if response.status_code == 401:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token is invalid or expired. Please re-authorize.",
)
if response.status_code != 200:
logger.error(f"OneDrive list children failed: {response.status_code} {response.text}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to list OneDrive folders: {response.text}",
)
data = response.json()
folders = []
for item in data.get("value", []):
if "folder" in item:
parent_path = ""
if item.get("parentReference", {}).get("path"):
# parentReference.path looks like /drive/root:/some/path
raw_parent = item["parentReference"]["path"]
prefix = "/drive/root:"
if raw_parent.startswith(prefix):
parent_path = raw_parent[len(prefix) :]
elif raw_parent == "/drive/root":
parent_path = ""
item_path = f"{parent_path}/{item['name']}" if parent_path else f"/{item['name']}"
folders.append(
{
"name": item["name"],
"path": item_path,
"id": item.get("id", ""),
"child_count": item.get("folder", {}).get("childCount", 0),
}
)
# Sort folders alphabetically
folders.sort(key=lambda f: f["name"].lower())
return {
"folders": folders,
"path": f"/{folder_path}" if folder_path else "/",
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Error listing OneDrive folders: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list folders: {str(e)}",
)
def format_time_remaining(time_delta):
"""Format a timedelta into a human-readable string."""
if time_delta.total_seconds() <= 0:
@@ -222,9 +302,9 @@ def format_time_remaining(time_delta):
@router.post("/onedrive/save-settings")
@require_login
async def save_onedrive_settings(
request: Request,
_admin: AdminUser,
refresh_token: Annotated[str, Form(...)],
client_id: Annotated[Optional[str], Form()] = None,
client_secret: Annotated[Optional[str], Form()] = None,
@@ -241,44 +321,26 @@ async def save_onedrive_settings(
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard"
)
# Best-effort .env file write
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token}
if client_id:
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
if client_secret:
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
if tenant_id:
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
if folder_path:
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
# Build settings dictionary mapped to database/memory keys
onedrive_settings = {
"onedrive_refresh_token": refresh_token,
"onedrive_client_id": client_id,
"onedrive_client_secret": client_secret,
"onedrive_tenant_id": tenant_id,
"onedrive_folder_path": folder_path,
}
if not update_env_file(env_path, onedrive_settings):
logger.info("Continuing with in-memory update despite .env file update failure or skip")
# Filter out None values
onedrive_settings = {k: v for k, v in onedrive_settings.items() if v is not None}
# Update the settings in memory
if refresh_token:
settings.onedrive_refresh_token = refresh_token
if client_id:
settings.onedrive_client_id = client_id
if client_secret:
settings.onedrive_client_secret = client_secret
if tenant_id:
settings.onedrive_tenant_id = tenant_id
if folder_path:
settings.onedrive_folder_path = folder_path
# Best-effort .env file write using the new utility
env_settings = {k.upper(): v for k, v in onedrive_settings.items()}
update_env_file(env_settings)
# Persist to database (primary)
if refresh_token:
save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by)
if client_id:
save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by)
if client_secret:
save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by)
if tenant_id:
save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by)
if folder_path:
save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by)
# Update in-memory settings and persist to database dynamically
for key, value in onedrive_settings.items():
setattr(settings, key, value)
save_setting_to_db(db, key, value, changed_by=changed_by)
notify_settings_updated()
+8 -2
View File
@@ -117,8 +117,14 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
},
"classify": {
"label": "Document Classification",
"description": "Classify the document type using AI without full metadata extraction.",
"config_schema": {},
"description": "Classify the document type using built-in and custom rules (filename patterns, content keywords, metadata matching).",
"config_schema": {
"use_builtin_rules": {
"type": "boolean",
"default": True,
"description": "Include the pre-built classification rules (invoice, contract, receipt, etc.).",
},
},
},
}
+16
View File
@@ -31,6 +31,7 @@ from pydantic import BaseModel, Field
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.audit_log import get_client_ip
from app.utils.session_manager import (
@@ -151,6 +152,11 @@ async def create_challenge(
displayed to the user. The mobile app scans this QR code and
calls the ``/claim`` endpoint.
"""
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
ip = get_client_ip(request)
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
@@ -187,6 +193,11 @@ async def poll_challenge_status(
The web UI calls this endpoint every few seconds to check if the
mobile app has scanned the QR code and claimed the challenge.
"""
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
result = get_challenge_status(db, challenge_id, owner_id)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
@@ -206,6 +217,11 @@ async def claim_challenge(
serves as proof that the user authorized this login from their web
session.
"""
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
ip = get_client_ip(request)
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
+62
View File
@@ -55,6 +55,12 @@ class SettingUpdate(BaseModel):
value: Optional[str] = Field(None, description="Setting value (None to delete)")
class SettingValueUpdate(BaseModel):
"""Model for updating a setting value by key (key is provided in the URL path)."""
value: Optional[str] = Field(None, description="Setting value (None to delete)")
class SettingResponse(BaseModel):
"""Model for setting response"""
@@ -323,6 +329,62 @@ async def update_setting(
)
@router.put("/{key}")
async def put_setting(
key: str,
body: SettingValueUpdate,
request: Request,
db: DbSession,
admin: AdminUser,
):
"""
Update a specific setting by key (RESTful PUT).
Accepts a body with only ``value``; the key is taken from the URL path.
This is the endpoint used by the admin Connections wizard.
Admin only.
"""
validate_setting_key(key)
try:
if body.value is not None:
is_valid, error_message = validate_setting_value(key, body.value)
if not is_valid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message)
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "admin"
)
success = save_setting_to_db(db, key, body.value, changed_by=changed_by)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save setting to database",
)
notify_settings_updated()
metadata = get_setting_metadata(key)
restart_required = metadata.get("restart_required", False)
return {
"success": True,
"message": f"Setting '{key}' updated successfully",
"restart_required": restart_required,
"key": key,
"value": body.value,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating setting {key}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update setting: {key}",
)
@router.delete("/{key}")
async def delete_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
"""
+355
View File
@@ -0,0 +1,355 @@
"""File-sharing API endpoints.
Provides CRUD operations for ``FileShare`` records, which grant named
users ``viewer`` or ``editor`` access to a document owned by someone
else. Only the file owner may create, update, or revoke shares.
"""
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
from app.models import FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLES, FileRecord, FileShare, UserProfile
from app.utils.user_scope import get_current_owner_id, get_file_role
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _serialize_share(share: FileShare) -> dict[str, Any]:
"""Serialize a ``FileShare`` to a JSON-friendly dict."""
return {
"id": share.id,
"file_id": share.file_id,
"owner_id": share.owner_id,
"shared_with_user_id": share.shared_with_user_id,
"role": share.role,
"created_at": share.created_at.isoformat() if share.created_at else None,
"updated_at": share.updated_at.isoformat() if share.updated_at else None,
}
def _require_owner(file_record: FileRecord, user_id: str | None, db: Session) -> None:
"""Raise 403 unless the calling user is the file owner."""
if get_file_role(file_record, user_id, db) != "owner":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the file owner can manage shares",
)
# ---------------------------------------------------------------------------
# List shares
# ---------------------------------------------------------------------------
@router.get("/files/{file_id}/shares")
@require_login
def list_shares(request: Request, file_id: int, db: DbSession):
"""List all shares for a document.
Only the file owner (or an admin) may call this endpoint.
Path Parameters:
file_id: The ID of the document.
Returns:
A list of share objects.
"""
user_id = get_current_owner_id(request)
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
role = get_file_role(file_record, user_id, db)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if role != "owner" and not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the file owner can view shares",
)
shares = db.query(FileShare).filter(FileShare.file_id == file_id).all()
return [_serialize_share(s) for s in shares]
# ---------------------------------------------------------------------------
# Create share
# ---------------------------------------------------------------------------
@router.post("/files/{file_id}/shares", status_code=status.HTTP_201_CREATED)
@require_login
def create_share(
request: Request,
file_id: int,
db: DbSession,
shared_with_user_id: str = Body(..., embed=True),
role: str = Body(FILE_SHARE_ROLE_VIEWER, embed=True),
):
"""Share a document with another user.
Only the file owner may share the document. Sharing with a user
that already has access updates their role instead of creating a
duplicate record.
Path Parameters:
file_id: The ID of the document to share.
Request body (JSON):
shared_with_user_id: The stable user identifier of the recipient.
role: ``"viewer"`` (default) or ``"editor"``.
Returns:
The created or updated share object.
"""
owner_id = get_current_owner_id(request)
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
_require_owner(file_record, owner_id, db)
if role not in FILE_SHARE_ROLES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}",
)
if not shared_with_user_id or not shared_with_user_id.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="shared_with_user_id must be a non-empty string",
)
shared_with_user_id = shared_with_user_id.strip()
# Cannot share with yourself
if shared_with_user_id == owner_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="You cannot share a file with yourself",
)
try:
existing = (
db.query(FileShare)
.filter(FileShare.file_id == file_id, FileShare.shared_with_user_id == shared_with_user_id)
.first()
)
if existing:
# Update role if different
if existing.role != role:
existing.role = role
db.commit()
db.refresh(existing)
logger.info(
"Share updated: file_id=%s, shared_with=%s, role=%s, by owner=%s",
file_id,
shared_with_user_id,
role,
owner_id,
)
return _serialize_share(existing)
share = FileShare(
file_id=file_id,
owner_id=owner_id,
shared_with_user_id=shared_with_user_id,
role=role,
)
db.add(share)
db.commit()
db.refresh(share)
except HTTPException:
raise
except Exception:
db.rollback()
logger.exception("Failed to create share: file_id=%s, shared_with=%s", file_id, shared_with_user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create share",
)
logger.info(
"Share created: id=%s, file_id=%s, shared_with=%s, role=%s, by owner=%s",
share.id,
file_id,
shared_with_user_id,
role,
owner_id,
)
return _serialize_share(share)
# ---------------------------------------------------------------------------
# Update share role
# ---------------------------------------------------------------------------
@router.put("/files/{file_id}/shares/{share_id}")
@require_login
def update_share(
request: Request,
file_id: int,
share_id: int,
db: DbSession,
role: str = Body(..., embed=True),
):
"""Update the role of an existing share.
Only the file owner may change the role of a share.
Path Parameters:
file_id: The ID of the document.
share_id: The ID of the share record to update.
Request body (JSON):
role: New role ``"viewer"`` or ``"editor"``.
Returns:
The updated share object.
"""
owner_id = get_current_owner_id(request)
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
_require_owner(file_record, owner_id, db)
if role not in FILE_SHARE_ROLES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}",
)
share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first()
if not share:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found")
try:
share.role = role
db.commit()
db.refresh(share)
except Exception:
db.rollback()
logger.exception("Failed to update share: share_id=%s", share_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update share",
)
logger.info("Share updated: id=%s, file_id=%s, new_role=%s, by owner=%s", share_id, file_id, role, owner_id)
return _serialize_share(share)
# ---------------------------------------------------------------------------
# Revoke share
# ---------------------------------------------------------------------------
@router.delete("/files/{file_id}/shares/{share_id}", status_code=status.HTTP_200_OK)
@require_login
def revoke_share(request: Request, file_id: int, share_id: int, db: DbSession):
"""Revoke a share, removing the user's access.
Only the file owner may revoke shares.
Path Parameters:
file_id: The ID of the document.
share_id: The ID of the share record to delete.
Returns:
A success message.
"""
owner_id = get_current_owner_id(request)
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
_require_owner(file_record, owner_id, db)
share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first()
if not share:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found")
try:
db.delete(share)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to revoke share: share_id=%s", share_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to revoke share",
)
logger.info("Share revoked: id=%s, file_id=%s, by owner=%s", share_id, file_id, owner_id)
return {"status": "success", "message": "Share revoked successfully"}
# ---------------------------------------------------------------------------
# List users that the file is already shared with (for the share-picker UI)
# ---------------------------------------------------------------------------
@router.get("/files/{file_id}/shared-with")
@require_login
def list_shared_with(request: Request, file_id: int, db: DbSession):
"""Return the list of users a document is shared with and their roles.
Accessible to any user that has at least viewer access to the file,
so that editors/viewers can see who else has access.
Path Parameters:
file_id: The ID of the document.
Returns:
A list of ``{share_id, user_id, display_name, role}`` objects.
"""
user_id = get_current_owner_id(request)
user = request.session.get("user")
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
role = get_file_role(file_record, user_id, db)
if role is None and not is_admin:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
shares = db.query(FileShare).filter(FileShare.file_id == file_id).all()
results = []
for s in shares:
profile = db.query(UserProfile).filter(UserProfile.user_id == s.shared_with_user_id).first()
results.append(
{
"share_id": s.id,
"user_id": s.shared_with_user_id,
"display_name": (profile.display_name if profile and profile.display_name else s.shared_with_user_id),
"role": s.role,
}
)
return results
+7 -2
View File
@@ -11,11 +11,12 @@ from typing import Optional
import aiofiles
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, HttpUrl, field_validator
from app.auth import require_login
from app.config import settings
from app.middleware.upload_rate_limit import require_upload_rate_limit
from app.tasks.process_document import process_document
from app.utils.allowed_types import ALLOWED_MIME_TYPES
from app.utils.filename_utils import sanitize_filename
@@ -107,7 +108,11 @@ def validate_file_type(content_type: str, filename: str) -> bool:
@router.post("/process-url")
@require_login
async def process_url(request: Request, url_request: URLUploadRequest):
async def process_url(
request: Request,
url_request: URLUploadRequest,
_rate_ok: None = Depends(require_upload_rate_limit),
):
"""
Download a file from a URL and enqueue it for processing.
+244 -29
View File
@@ -45,9 +45,79 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
# Social login providers that are enabled and registered
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
oauth.register(
name="authentik",
# ---------------------------------------------------------------------------
# Helpers for dynamic (re-)registration of OAuth providers
# ---------------------------------------------------------------------------
def _register_oauth_client(name: str, **kwargs: object) -> None:
"""Register (or re-register) an authlib OAuth client, clearing any cached instance.
authlib caches the constructed client object in ``oauth._clients`` after the
first ``register()`` call. Subsequent ``register()`` calls overwrite the
registry entry but the stale cached client is still returned by
``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
before re-registering ensures the new credentials are picked up immediately.
Args:
name: Provider name (e.g. ``"google"``, ``"github"``).
**kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
"""
oauth._clients.pop(name, None)
oauth.register(name, **kwargs)
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
"""Normalize Dropbox userinfo response for authlib compatibility.
Dropbox's /2/users/get_current_account returns a non-standard response
format. This compliance fix normalizes the response data the HTTP
method (POST) is handled by authlib's compliance infrastructure.
Args:
client: The OAuth client instance (required by authlib compliance fix interface).
user_cls: The user class (required by authlib compliance fix interface).
token: The OAuth token dict.
data: The raw userinfo response dict from Dropbox.
Returns:
The normalized userinfo dict with ``sub`` and ``name`` fields.
"""
# Dropbox returns account_id instead of sub
if "account_id" in data and "sub" not in data:
data["sub"] = data["account_id"]
# Normalize name field
name_info = data.get("name", {})
if isinstance(name_info, dict) and "display_name" in name_info:
data["name"] = name_info["display_name"]
return data
def _setup_social_providers() -> None:
"""Register all configured OAuth / social-login providers from current settings.
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
and calls :func:`_register_oauth_client` (which also clears the authlib
client cache) so that credential changes in the database are reflected
without an application restart.
Can safely be called multiple times, e.g. after a settings reload.
"""
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
SOCIAL_PROVIDERS.clear()
OAUTH_CONFIGURED = False
OAUTH_PROVIDER_NAME = "Single Sign-On"
if not AUTH_ENABLED:
return
# --- Authentik / OIDC ---
if settings.authentik_client_id and settings.authentik_client_secret:
_register_oauth_client(
"authentik",
client_id=settings.authentik_client_id,
client_secret=settings.authentik_client_secret,
server_metadata_url=settings.authentik_config_url,
@@ -56,13 +126,21 @@ if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_s
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
# --- Social Login Providers ---------------------------------------------------
if AUTH_ENABLED and settings.social_auth_google_enabled:
if settings.social_auth_google_client_id and settings.social_auth_google_client_secret:
oauth.register(
name="google",
client_id=settings.social_auth_google_client_id,
client_secret=settings.social_auth_google_client_secret,
# --- Social Login Providers ---
# Google
if settings.social_auth_google_enabled:
_google_client_id = settings.social_auth_google_client_id
_google_client_secret = settings.social_auth_google_client_secret
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
_google_client_id = settings.google_drive_client_id
_google_client_secret = settings.google_drive_client_secret
if _google_client_id and _google_client_secret:
_register_oauth_client(
"google",
client_id=_google_client_id,
client_secret=_google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
@@ -71,13 +149,22 @@ if AUTH_ENABLED and settings.social_auth_google_enabled:
else:
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
if settings.social_auth_microsoft_client_id and settings.social_auth_microsoft_client_secret:
# Microsoft
if settings.social_auth_microsoft_enabled:
_microsoft_client_id = settings.social_auth_microsoft_client_id
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
if settings.social_auth_microsoft_use_global_credentials and not (
_microsoft_client_id and _microsoft_client_secret
):
_microsoft_client_id = settings.onedrive_client_id
_microsoft_client_secret = settings.onedrive_client_secret
if _microsoft_client_id and _microsoft_client_secret:
tenant = settings.social_auth_microsoft_tenant or "common"
oauth.register(
name="microsoft",
client_id=settings.social_auth_microsoft_client_id,
client_secret=settings.social_auth_microsoft_client_secret,
_register_oauth_client(
"microsoft",
client_id=_microsoft_client_id,
client_secret=_microsoft_client_secret,
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
@@ -86,10 +173,11 @@ if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
else:
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_apple_enabled:
# Apple
if settings.social_auth_apple_enabled:
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
oauth.register(
name="apple",
_register_oauth_client(
"apple",
client_id=settings.social_auth_apple_client_id,
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
client_kwargs={
@@ -102,22 +190,120 @@ if AUTH_ENABLED and settings.social_auth_apple_enabled:
else:
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:
oauth.register(
name="dropbox",
client_id=settings.social_auth_dropbox_client_id,
client_secret=settings.social_auth_dropbox_client_secret,
# Dropbox
if settings.social_auth_dropbox_enabled:
_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 and _dropbox_client_secret):
_dropbox_client_id = settings.dropbox_app_key
_dropbox_client_secret = settings.dropbox_app_secret
if _dropbox_client_id and _dropbox_client_secret:
_register_oauth_client(
"dropbox",
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",
client_kwargs={"token_endpoint_auth_method": "client_secret_post"},
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
client_kwargs={
"token_endpoint_auth_method": "client_secret_post",
"token_access_type": "offline",
},
)
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
logger.info("Social login provider registered: Dropbox")
else:
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
# GitHub
if settings.social_auth_github_enabled:
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
_register_oauth_client(
"github",
client_id=settings.social_auth_github_client_id,
client_secret=settings.social_auth_github_client_secret,
authorize_url="https://github.com/login/oauth/authorize",
access_token_url="https://github.com/login/oauth/access_token",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": "read:user user:email"},
)
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
logger.info("Social login provider registered: GitHub")
else:
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
# Keycloak
if settings.social_auth_keycloak_enabled:
_kc_server = settings.social_auth_keycloak_server_url
_kc_realm = settings.social_auth_keycloak_realm
if (
settings.social_auth_keycloak_client_id
and settings.social_auth_keycloak_client_secret
and _kc_server
and _kc_realm
):
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
_register_oauth_client(
"keycloak",
client_id=settings.social_auth_keycloak_client_id,
client_secret=settings.social_auth_keycloak_client_secret,
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
else:
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
# Generic OAuth2
if settings.social_auth_generic_oauth2_enabled:
if (
settings.social_auth_generic_oauth2_client_id
and settings.social_auth_generic_oauth2_client_secret
and settings.social_auth_generic_oauth2_authorize_url
and settings.social_auth_generic_oauth2_token_url
):
_register_oauth_client(
"generic_oauth2",
client_id=settings.social_auth_generic_oauth2_client_id,
client_secret=settings.social_auth_generic_oauth2_client_secret,
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
access_token_url=settings.social_auth_generic_oauth2_token_url,
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
)
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
SOCIAL_PROVIDERS["generic_oauth2"] = {
"name": _generic_name,
"icon": "fas fa-sign-in-alt",
"color": "indigo",
}
logger.info("Social login provider registered: Generic OAuth2")
else:
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
def refresh_social_providers() -> None:
"""Re-register all OAuth providers from the *current* settings object.
Call this after loading or reloading settings from the database so that
providers configured (or updated) through the admin UI take effect
immediately **no application restart required**.
This function is safe to call multiple times and is idempotent.
"""
logger.info("Refreshing social login provider registrations from current settings")
_setup_social_providers()
# Perform the initial registration from environment / default settings at
# import time. The lifespan hook and settings_sync will call
# refresh_social_providers() again after DB settings are loaded so that
# any providers configured only in the database are also active.
_setup_social_providers()
router = APIRouter()
@@ -186,6 +372,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,
@@ -331,13 +527,21 @@ async def login(request: Request):
get_client_ip(request),
)
error = request.query_params.get("error")
message = request.query_params.get("message")
show_oauth = OAUTH_CONFIGURED
# SSO Auto Login: redirect directly to SSO provider if configured
if show_oauth and settings.sso_auto_login is True and not error and not message:
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
return templates.TemplateResponse(
request,
"login.html",
context={
"error": request.query_params.get("error"),
"message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED,
"error": error,
"message": message,
"show_oauth": show_oauth,
"oauth_provider_name": OAUTH_PROVIDER_NAME,
"social_providers": SOCIAL_PROVIDERS,
"app_version": settings.version,
@@ -423,6 +627,17 @@ def _normalize_social_userinfo(provider: str, token: dict, raw_userinfo: dict |
"picture": userinfo.get("profile_photo_url", ""),
}
if provider == "github":
# GitHub returns login, id, name, email, avatar_url
email = userinfo.get("email", "")
return {
"sub": str(userinfo.get("id", "")),
"email": email,
"name": userinfo.get("name", "") or userinfo.get("login", ""),
"preferred_username": userinfo.get("login", email),
"picture": userinfo.get("avatar_url", ""),
}
# Standard OIDC providers (Google, Microsoft, Apple)
return {
"sub": userinfo.get("sub", ""),
+2
View File
@@ -10,6 +10,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.tasks.automation_tasks import deliver_automation_hook_task # noqa: F401
from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401
from app.tasks.batch_tasks import ( # noqa: F401
backfill_missing_metadata,
@@ -22,6 +23,7 @@ from app.tasks.batch_tasks import ( # noqa: F401
sync_search_index,
)
from app.tasks.check_credentials import check_credentials
from app.tasks.classify_document import classify_document_task # noqa: F401
from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
from app.tasks.convert_to_pdfa import convert_to_pdfa # noqa: F401
+161 -30
View File
@@ -13,6 +13,24 @@ class Settings(BaseSettings):
database_url: str
redis_url: str
# Database connection-pool tuning (ignored for SQLite, which uses NullPool).
db_pool_size: int = Field(
default=10,
description="Number of persistent connections kept in the pool per worker process.",
)
db_max_overflow: int = Field(
default=20,
description="Additional connections allowed beyond db_pool_size under burst load.",
)
db_pool_timeout: int = Field(
default=30,
description="Seconds to wait for a connection from the pool before raising a TimeoutError.",
)
db_pool_recycle: int = Field(
default=1800,
description="Recycle (close and reopen) connections after this many seconds to avoid stale connections.",
)
openai_api_key: str
openai_base_url: str = "https://api.openai.com/v1" # Default to OpenAI's endpoint
openai_model: str = "gpt-4o-mini" # Default model
@@ -102,6 +120,16 @@ class Settings(BaseSettings):
dropbox_app_secret: Optional[str] = None
dropbox_folder: Optional[str] = None
dropbox_refresh_token: Optional[str] = None
dropbox_allow_global_credentials_for_integrations: bool = Field(
default=False,
description=(
"When True, users may authorize their personal Dropbox integrations using the global "
"DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without "
"needing to create their own Dropbox app. The Dropbox OAuth flow is initiated "
"server-side so the app secret is never exposed to the browser. "
"Default: False (each user must supply their own app credentials)."
),
)
# Making Nextcloud optional
nextcloud_enabled: bool = Field(
@@ -165,6 +193,16 @@ class Settings(BaseSettings):
google_docai_processor_id: Optional[str] = None
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
external_hostname: str = "localhost" # Default to localhost
public_base_url: Optional[str] = Field(
default=None,
description=(
"The full public base URL of the application, including scheme "
"(e.g., 'https://docuelevate.example.com'). "
"When set, this overrides the auto-detected URL for OAuth redirect URIs. "
"This is required when the application is behind a reverse proxy that does "
"not forward X-Forwarded-Proto headers correctly."
),
)
# ---------------------------------------------------------------------------
# Document Translation Settings
@@ -206,6 +244,10 @@ class Settings(BaseSettings):
"Useful for admin-configured non-standard durations."
),
)
qr_login_enabled: bool = Field(
default=True,
description="Enable QR code-based login for mobile device authentication (default: True).",
)
qr_login_challenge_ttl_seconds: int = Field(
default=120,
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
@@ -267,12 +309,55 @@ class Settings(BaseSettings):
authentik_client_secret: Optional[str] = None
authentik_config_url: Optional[str] = None
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
sso_auto_login: bool = Field(
default=False,
description=(
"Automatically redirect to SSO login when authentication is required. "
"When enabled, users are sent directly to the SSO provider instead of "
"seeing the login page. Only effective when OIDC is configured."
),
)
# Keycloak SSO
social_auth_keycloak_enabled: bool = False
social_auth_keycloak_client_id: Optional[str] = None
social_auth_keycloak_client_secret: Optional[str] = None
social_auth_keycloak_server_url: Optional[str] = None
social_auth_keycloak_realm: Optional[str] = None
# Generic OAuth2 SSO
social_auth_generic_oauth2_enabled: bool = False
social_auth_generic_oauth2_client_id: Optional[str] = None
social_auth_generic_oauth2_client_secret: Optional[str] = None
social_auth_generic_oauth2_authorize_url: Optional[str] = None
social_auth_generic_oauth2_token_url: Optional[str] = None
social_auth_generic_oauth2_userinfo_url: Optional[str] = None
social_auth_generic_oauth2_scope: str = "openid profile email"
social_auth_generic_oauth2_name: str = "OAuth2"
# SAML2 SSO
social_auth_saml2_enabled: bool = False
social_auth_saml2_entity_id: Optional[str] = None
social_auth_saml2_sso_url: Optional[str] = None
social_auth_saml2_certificate: Optional[str] = None
social_auth_saml2_name: str = "SAML2"
# Social Login Providers
# Google OAuth2
social_auth_google_enabled: bool = False
social_auth_google_client_id: Optional[str] = None
social_auth_google_client_secret: Optional[str] = None
social_auth_google_use_global_credentials: bool = Field(
default=False,
description=(
"When True, Google social login uses the global GOOGLE_DRIVE_CLIENT_ID / "
"GOOGLE_DRIVE_CLIENT_SECRET credentials (the Google Drive OAuth integration credentials) "
"instead of requiring separate SOCIAL_AUTH_GOOGLE_CLIENT_ID / "
"SOCIAL_AUTH_GOOGLE_CLIENT_SECRET values. "
"Requires SOCIAL_AUTH_GOOGLE_ENABLED=True and the global Google Drive OAuth credentials to be set. "
"Default: False."
),
)
# Microsoft OAuth2 (Azure AD / Microsoft Entra ID)
social_auth_microsoft_enabled: bool = False
@@ -287,6 +372,17 @@ class Settings(BaseSettings):
"Default: common."
),
)
social_auth_microsoft_use_global_credentials: bool = Field(
default=False,
description=(
"When True, Microsoft social login uses the global ONEDRIVE_CLIENT_ID / "
"ONEDRIVE_CLIENT_SECRET credentials (the OneDrive integration credentials) "
"instead of requiring separate SOCIAL_AUTH_MICROSOFT_CLIENT_ID / "
"SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET values. "
"Requires SOCIAL_AUTH_MICROSOFT_ENABLED=True and the global OneDrive credentials to be set. "
"Default: False."
),
)
# Apple Sign-In
social_auth_apple_enabled: bool = False
@@ -299,6 +395,21 @@ 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."
),
)
# GitHub OAuth2
social_auth_github_enabled: bool = False
social_auth_github_client_id: Optional[str] = None
social_auth_github_client_secret: Optional[str] = None
# Local user signup
allow_local_signup: bool = Field(
@@ -796,6 +907,11 @@ class Settings(BaseSettings):
),
)
# Telegram Bot
telegram_bot_token: Optional[str] = None
telegram_chat_id: Optional[str] = None
telegram_enabled: bool = False
# Notification settings
notification_urls: Union[List[str], str] = Field(
default_factory=list,
@@ -830,6 +946,12 @@ class Settings(BaseSettings):
description="Enable webhook delivery for document events",
)
# Automation hooks (Zapier / Make.com)
automation_hooks_enabled: bool = Field(
default=True,
description="Enable Zapier / Make.com automation hook subscriptions and delivery",
)
# ── Backup / restore settings ──────────────────────────────────────────────
backup_enabled: bool = Field(
default=True,
@@ -1115,43 +1237,18 @@ class Settings(BaseSettings):
),
)
# Database Connection Pool Configuration
# Controls SQLAlchemy QueuePool behaviour for PostgreSQL/MySQL.
# SQLite uses NullPool and ignores these settings.
db_pool_size: int = Field(
default=5,
description="Number of persistent connections kept in the pool. Ignored for SQLite.",
)
db_max_overflow: int = Field(
default=10,
description=("Maximum number of connections that can be opened beyond db_pool_size. Ignored for SQLite."),
)
db_pool_timeout: int = Field(
default=30,
description="Seconds to wait for a connection from the pool before raising an error. Ignored for SQLite.",
)
db_pool_recycle: int = Field(
default=1800,
description=(
"Seconds after which a connection is recycled to prevent stale connections. "
"Ignored for SQLite. Default: 1800 (30 minutes)."
),
)
# Per-user upload rate limiting (health-aware limiter)
# Controls how many uploads a single user may submit within a sliding window.
# Per-user upload rate limiting (health-aware, Redis-backed sliding window)
upload_rate_limit_per_user: int = Field(
default=20,
description=(
"Maximum number of uploads allowed per user within the upload_rate_limit_window. "
"The limiter may dynamically reduce this value when Redis queue depth or CPU load is high."
"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 in seconds over which upload_rate_limit_per_user is enforced. Default: 60 seconds."
),
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)
@@ -1280,6 +1377,40 @@ class Settings(BaseSettings):
),
)
# ---------------------------------------------------------------------------
# Observability Sentry Browser JavaScript SDK (client-side)
# ---------------------------------------------------------------------------
# The same SENTRY_DSN is reused for the browser SDK. The DSN is a *public*
# key in Sentry's model and is intentionally embedded in client-side code.
# All three settings below default to 0.0 / disabled so that operators opt-in
# to the level of browser monitoring they want.
# ---------------------------------------------------------------------------
sentry_js_traces_sample_rate: float = Field(
default=0.0,
description=(
"Fraction of browser page-loads captured for client-side performance tracing "
"(0.0 1.0). 0.0 disables browser tracing; 1.0 captures every navigation. "
"Only active when SENTRY_DSN is set."
),
)
sentry_js_replay_session_sample_rate: float = Field(
default=0.0,
description=(
"Fraction of sessions recorded by Sentry Session Replay (0.0 1.0). "
"0.0 disables session recording; 1.0 records every session. "
"Only active when SENTRY_DSN is set."
),
)
sentry_js_replay_on_error_sample_rate: float = Field(
default=0.1,
description=(
"Fraction of sessions with an error that will be recorded by Sentry Session "
"Replay (0.0 1.0). Defaults to 0.1 (10 %) so that errors are captured "
"with replay context even when session-level recording is disabled. "
"Only active when SENTRY_DSN is set."
),
)
@model_validator(mode="before")
@classmethod
def strip_outer_quotes(cls, data: Any) -> Any:
+28 -13
View File
@@ -18,22 +18,37 @@ logger = logging.getLogger(__name__)
Base = declarative_base()
# Parse the DATABASE_URL
# ---------------------------------------------------------------------------
# Engine construction
# ---------------------------------------------------------------------------
DB_URL = settings.database_url
_db_url = make_url(DB_URL)
if _db_url.get_backend_name() == "sqlite":
# SQLite does not benefit from connection pooling; NullPool avoids contention.
engine = create_engine(DB_URL, connect_args={"check_same_thread": False}, poolclass=NullPool)
_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 / other: use a configurable QueuePool.
engine = create_engine(
DB_URL,
poolclass=QueuePool,
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
pool_recycle=settings.db_pool_recycle,
# 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)
+12 -6
View File
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
finally:
db.close()
# Re-register OAuth / social-login providers now that DB settings are
# loaded. auth.py runs its initial registration at import time (before
# the lifespan runs), so providers that are only configured in the
# database would not be registered yet. Calling refresh here ensures
# they are active immediately on startup without any manual restart.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as e:
logging.warning(f"Could not refresh social login providers on startup: {e}")
# Initialize Sentry after DB settings are loaded so that values configured
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
init_sentry()
@@ -282,16 +294,10 @@ async def lifespan(app: FastAPI):
yield
# Shutdown: Cleanup tasks
try:
logging.info("Application shutting down")
except Exception: # noqa: S110
pass # During test teardown, logging streams may already be closed
# Send shutdown notification
try:
notify_shutdown()
except Exception: # noqa: S110
pass # During test teardown, I/O streams may already be closed
app = FastAPI(
+290
View File
@@ -0,0 +1,290 @@
"""Per-user, health-aware upload rate limiter for DocuElevate.
This module provides a FastAPI dependency that enforces per-user upload rate
limits using a Redis-backed sliding window counter. The effective limit is
dynamically reduced when the system is under heavy load (high Celery queue
depth or elevated CPU load average), ensuring the server remains responsive
to all users even during bulk-upload scenarios.
Usage in an endpoint::
from app.middleware.upload_rate_limit import require_upload_rate_limit
@router.post("/ui-upload")
@require_login
async def ui_upload(
request: Request,
_rate_ok: None = Depends(require_upload_rate_limit),
...
):
...
See ``docs/ConfigurationGuide.md`` for the configuration options
(``UPLOAD_RATE_LIMIT_PER_USER``, ``UPLOAD_RATE_LIMIT_WINDOW``).
"""
from __future__ import annotations
import logging
import os
import time
from typing import Any
import redis
from fastapi import HTTPException, Request, status
from app.config import settings
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Redis key prefix
# ---------------------------------------------------------------------------
_KEY_PREFIX = "docuelevate:upload_rate"
# ---------------------------------------------------------------------------
# Health-check queue names (Celery defaults used by DocuElevate)
# ---------------------------------------------------------------------------
_CELERY_QUEUES = ("document_processor", "default", "celery")
# ---------------------------------------------------------------------------
# Singleton Redis client (lazy-initialised; fail-open when unavailable)
# ---------------------------------------------------------------------------
_redis_client: redis.Redis | None = None
def _get_redis() -> redis.Redis | None:
"""Return a shared Redis client, or *None* when Redis is unavailable."""
global _redis_client
if _redis_client is not None:
return _redis_client
try:
_redis_client = redis.Redis.from_url(
settings.redis_url,
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
)
# Quick connectivity check raises on failure.
_redis_client.ping()
return _redis_client
except Exception: # noqa: BLE001
logger.debug("Redis unavailable for upload rate limiter falling back to allow-all", exc_info=True)
_redis_client = None
return None
# ---------------------------------------------------------------------------
# Health metrics helpers
# ---------------------------------------------------------------------------
def _get_queue_depth(r: redis.Redis) -> int:
"""Return the total number of pending tasks across all Celery queues."""
total = 0
for queue_name in _CELERY_QUEUES:
try:
total += r.llen(queue_name)
except Exception: # noqa: BLE001, S110
logger.debug("Could not read queue length for %r", queue_name, exc_info=True)
return total
def _get_cpu_load_ratio() -> float:
"""Return the 1-minute load average divided by the number of CPU cores.
Returns ``0.0`` on platforms that do not support :func:`os.getloadavg`
(e.g. Windows) so that the limiter never penalises on those systems.
"""
try:
load_1m = os.getloadavg()[0]
cpu_count = os.cpu_count() or 1
return load_1m / cpu_count
except (OSError, AttributeError):
return 0.0
def compute_effective_limit(
base_limit: int,
queue_depth: int = 0,
cpu_load_ratio: float = 0.0,
) -> tuple[int, float, str]:
"""Compute the effective upload rate limit based on system health.
The function applies a *reduction factor* (``0.0 < factor 1.0``) to the
configured base limit. Both queue depth and CPU load contribute
independently; the lowest factor wins.
Args:
base_limit: The configured maximum uploads per window.
queue_depth: Total pending tasks in Celery queues.
cpu_load_ratio: 1-minute load average divided by CPU count.
Returns:
A 3-tuple of ``(effective_limit, factor, reason)`` where *reason*
is a human-readable tag for logging.
"""
factor = 1.0
reason = "normal"
# --- Queue-depth thresholds ---
if queue_depth > 200:
factor, reason = min(factor, 0.10), f"critical_queue({queue_depth})"
elif queue_depth > 100:
factor, reason = min(factor, 0.25), f"high_queue({queue_depth})"
elif queue_depth > 50:
factor, reason = min(factor, 0.50), f"moderate_queue({queue_depth})"
# --- CPU-load thresholds ---
if cpu_load_ratio > 3.0:
new_factor = 0.10
if new_factor < factor:
factor, reason = new_factor, f"critical_cpu({cpu_load_ratio:.1f})"
elif cpu_load_ratio > 2.0:
new_factor = 0.25
if new_factor < factor:
factor, reason = new_factor, f"high_cpu({cpu_load_ratio:.1f})"
elif cpu_load_ratio > 1.5:
new_factor = 0.50
if new_factor < factor:
factor, reason = new_factor, f"moderate_cpu({cpu_load_ratio:.1f})"
effective = max(1, int(base_limit * factor))
return effective, factor, reason
# ---------------------------------------------------------------------------
# Core sliding-window check (Redis sorted set)
# ---------------------------------------------------------------------------
def _check_and_record(
r: redis.Redis,
user_id: str,
window: int,
effective_limit: int,
) -> dict[str, Any] | None:
"""Atomically check the user's upload count and record the new upload.
Uses a Redis sorted set where each member is a unique timestamp-based ID
and the score is the Unix timestamp. Entries older than *window* seconds
are pruned on every call so the set never grows unbounded.
Returns:
``None`` if the request is allowed, or a ``dict`` with ``count``,
``limit``, and ``retry_after`` if the limit is exceeded.
"""
key = f"{_KEY_PREFIX}:{user_id}"
now = time.time()
window_start = now - window
pipe = r.pipeline(transaction=True)
# 1. Remove entries outside the window
pipe.zremrangebyscore(key, "-inf", window_start)
# 2. Count current entries
pipe.zcard(key)
# 3. Retrieve the oldest entry's score (to compute retry_after)
pipe.zrange(key, 0, 0, withscores=True)
results = pipe.execute()
current_count: int = results[1]
oldest_entries: list = results[2]
if current_count >= effective_limit:
# Compute how long until the oldest entry expires from the window.
if oldest_entries:
oldest_score = oldest_entries[0][1]
retry_after = max(1, int((oldest_score + window) - now))
else:
retry_after = max(1, window // 2)
return {
"count": current_count,
"limit": effective_limit,
"retry_after": retry_after,
}
# 4. Record this upload (unique member = timestamp with random suffix)
member = f"{now}:{os.urandom(4).hex()}"
pipe2 = r.pipeline(transaction=True)
pipe2.zadd(key, {member: now})
pipe2.expire(key, window + 60) # TTL slightly longer than window
pipe2.execute()
return None
# ---------------------------------------------------------------------------
# FastAPI dependency
# ---------------------------------------------------------------------------
async def require_upload_rate_limit(request: Request) -> None:
"""FastAPI dependency that enforces per-user upload rate limits.
The dependency is designed to **fail open**: if Redis is unavailable the
request is allowed through so that uploads are never blocked by a
monitoring outage.
Raises:
HTTPException: 429 Too Many Requests when the per-user upload limit
is exceeded. The ``Retry-After`` header indicates how many
seconds the client should wait before retrying.
"""
r = _get_redis()
if r is None:
# Redis unavailable fail open.
return
# Identify the user (owner_id for multi-user, IP fallback).
user_id = get_current_owner_id(request)
if not user_id:
user_id = f"ip:{request.client.host}" if request.client else "ip:unknown"
base_limit: int = settings.upload_rate_limit_per_user
window: int = settings.upload_rate_limit_window
# Gather health metrics and compute effective limit.
try:
queue_depth = _get_queue_depth(r)
except Exception: # noqa: BLE001
queue_depth = 0
cpu_load_ratio = _get_cpu_load_ratio()
effective_limit, factor, health_reason = compute_effective_limit(base_limit, queue_depth, cpu_load_ratio)
# Sliding-window check.
try:
rejection = _check_and_record(r, user_id, window, effective_limit)
except Exception as exc: # noqa: BLE001
logger.warning("Upload rate-limit check failed (allowing request): %s", exc)
return
if rejection is not None:
retry_after = rejection["retry_after"]
logger.warning(
"Upload rate limit exceeded: user=%s count=%d/%d window=%ds health=%s retry_after=%ds",
user_id,
rejection["count"],
rejection["limit"],
window,
health_reason,
retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Upload rate limit exceeded ({rejection['count']}/{rejection['limit']} "
f"in {window}s). Retry after {retry_after}s."
),
headers={"Retry-After": str(retry_after)},
)
if factor < 1.0:
logger.info(
"Upload allowed with reduced limit: user=%s effective=%d/%d health=%s",
user_id,
effective_limit,
base_limit,
health_reason,
)
+164
View File
@@ -211,6 +211,28 @@ class WebhookConfig(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class AutomationHook(Base):
"""Zapier / Make.com compatible webhook subscription for automation triggers.
External automation platforms subscribe to DocuElevate events via the REST
hooks protocol. When an event fires, DocuElevate POSTs a Zapier-compatible
flat JSON payload to ``target_url``. The ``hook_type`` field records which
platform created the subscription (informational only).
"""
__tablename__ = "automation_hooks"
id = Column(Integer, primary_key=True, index=True)
target_url = Column(String, nullable=False) # URL to POST events to
secret = Column(String, nullable=True) # Optional HMAC-SHA256 signing secret
events = Column(Text, nullable=False) # JSON list of subscribed event names
is_active = Column(Boolean, default=True, nullable=False)
hook_type = Column(String(50), nullable=False, default="generic") # zapier | make | generic
description = Column(String, nullable=True) # Optional human-readable label
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class LocalUser(Base):
"""A locally-registered user authenticated by email and bcrypt password.
@@ -786,6 +808,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.
@@ -937,6 +962,51 @@ class ScheduledJob(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ClassificationRuleModel(Base):
"""Custom document classification rule.
Rules are evaluated during the ``classify`` pipeline step to assign a
category to a document. System-wide rules have ``owner_id IS NULL``;
user-specific rules belong to a single owner.
"""
__tablename__ = "classification_rules"
id = Column(Integer, primary_key=True, index=True)
# NULL = system-wide rule visible to all users.
owner_id = Column(String, nullable=True, index=True)
# Human-readable rule name (unique per owner).
name = Column(String(255), nullable=False)
# Target category (e.g. "invoice", "contract", "receipt").
category = Column(String(100), nullable=False, index=True)
# Rule type: "filename_pattern", "content_keyword", or "metadata_match".
rule_type = Column(String(50), nullable=False)
# The matching pattern:
# - filename_pattern: a regex
# - content_keyword: pipe-separated keywords
# - metadata_match: "field=value"
pattern = Column(String(1000), nullable=False)
# Higher priority rules are evaluated first (default 0).
priority = Column(Integer, nullable=False, default=0)
# Whether pattern matching is case-sensitive.
case_sensitive = Column(Boolean, nullable=False, default=False)
# Disabled rules are skipped during classification.
enabled = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_classification_rules_owner_name"),)
class MobileDevice(Base):
"""Registered mobile device for push notifications.
@@ -1122,3 +1192,97 @@ class PipelineRoutingRule(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class DocumentComment(Base):
"""Threaded comment on a document.
Supports threaded replies via ``parent_id`` and @mentions via the
``mentions`` column (comma-separated user identifiers).
"""
__tablename__ = "document_comments"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
user_id = Column(String, nullable=False, index=True)
parent_id = Column(Integer, ForeignKey("document_comments.id"), nullable=True, index=True)
body = Column(Text, nullable=False)
mentions = Column(Text, nullable=True)
is_resolved = Column(Boolean, nullable=False, default=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class DocumentAnnotation(Base):
"""Text annotation on a specific page and position of a PDF document.
Stores the bounding-box coordinates (``x``, ``y``, ``width``,
``height``) relative to the page dimensions so that the annotation
can be rendered on top of the PDF viewer.
"""
__tablename__ = "document_annotations"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
user_id = Column(String, nullable=False, index=True)
page = Column(Integer, nullable=False)
x = Column(Float, nullable=False)
y = Column(Float, nullable=False)
width = Column(Float, nullable=False, default=0)
height = Column(Float, nullable=False, default=0)
content = Column(Text, nullable=False)
annotation_type = Column(String(50), nullable=False, default="note", server_default="note")
color = Column(String(20), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# ---------------------------------------------------------------------------
# File sharing
# ---------------------------------------------------------------------------
# Valid roles for FileShare.role
FILE_SHARE_ROLE_VIEWER = "viewer"
FILE_SHARE_ROLE_EDITOR = "editor"
FILE_SHARE_ROLES = (FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLE_EDITOR)
class FileShare(Base):
"""Grants a named user access to a ``FileRecord`` owned by someone else.
The ``owner_id`` column records who created the share (must be the file
owner). ``shared_with_user_id`` is the recipient's stable user
identifier (the same kind of string used in ``FileRecord.owner_id``).
Roles
-----
``viewer`` can read the file, comments, and annotations; may add
comments/annotations; cannot delete or share.
``editor`` all viewer rights plus the ability to edit document
metadata; cannot delete or re-share.
Only the file owner may create, update, or revoke shares.
"""
__tablename__ = "file_shares"
id = Column(Integer, primary_key=True, index=True)
# The document being shared.
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
# The user who granted the share (must match FileRecord.owner_id).
owner_id = Column(String, nullable=False, index=True)
# The user receiving the share.
shared_with_user_id = Column(String, nullable=False, index=True)
# "viewer" or "editor"
role = Column(String(20), nullable=False, default=FILE_SHARE_ROLE_VIEWER)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("file_id", "shared_with_user_id", name="uq_file_share_file_user"),)
+44
View File
@@ -0,0 +1,44 @@
"""Celery task for asynchronous automation hook delivery with retry and backoff.
Uses :class:`~app.tasks.retry_config.BaseTaskWithRetry` so failed deliveries
are automatically retried with exponential backoff (default: 60 s, 300 s,
900 s) and ±20 % jitter.
"""
import logging
from typing import Any
from app.celery_app import celery
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils.webhook import deliver_webhook
logger = logging.getLogger(__name__)
@celery.task(base=BaseTaskWithRetry, bind=True, name="automation.deliver_hook")
def deliver_automation_hook_task(self, url: str, payload: dict[str, Any], secret: str | None = None) -> dict[str, Any]:
"""Deliver an automation hook payload to *url* with automatic retries.
Args:
url: Target webhook URL (provided by Zapier / Make.com).
payload: The flat Zapier-compatible payload.
secret: Optional shared secret for HMAC-SHA256 signing.
Returns:
A dict with ``status`` and ``url`` on success.
Raises:
RuntimeError: Re-raised to trigger Celery retry on delivery failure.
"""
logger.info(
"Delivering automation hook to %s (attempt %d/%d)",
url,
self.request.retries + 1,
self.max_retries + 1,
)
success = deliver_webhook(url, payload, secret)
if success:
return {"status": "delivered", "url": url}
raise RuntimeError(f"Automation hook delivery to {url} failed")
+174
View File
@@ -0,0 +1,174 @@
"""Celery task for rule-based document classification.
This task is executed as a pipeline step (``step_type="classify"``). It
applies built-in and user-defined classification rules against the document's
filename, OCR text, and existing AI metadata to assign a ``document_type``
category.
The result is stored in the ``ai_metadata`` JSON blob on the
:class:`~app.models.FileRecord` (field ``classification``).
"""
from __future__ import annotations
import json
import logging
from typing import Any
from app.celery_app import celery
from app.database import SessionLocal
from app.models import ClassificationRuleModel, FileRecord
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils import log_task_progress
from app.utils.classification_rules import (
ClassificationResult,
classify_document,
db_rule_to_engine_rule,
)
logger = logging.getLogger(__name__)
STEP_NAME = "classify_document"
def _load_custom_rules(owner_id: str | None) -> list[Any]:
"""Load enabled custom classification rules from the database.
Returns engine-level :class:`ClassificationRule` dataclass instances.
Rules are loaded in priority-descending order. System rules
(``owner_id IS NULL``) and the user's own rules are both included.
"""
with SessionLocal() as db:
query = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.enabled.is_(True))
if owner_id:
query = query.filter(
(ClassificationRuleModel.owner_id.is_(None)) | (ClassificationRuleModel.owner_id == owner_id)
)
else:
query = query.filter(ClassificationRuleModel.owner_id.is_(None))
rules = query.order_by(ClassificationRuleModel.priority.desc()).all()
return [db_rule_to_engine_rule(r) for r in rules]
@celery.task(base=BaseTaskWithRetry, bind=True)
def classify_document_task(
self: Any,
file_id: int,
owner_id: str | None = None,
) -> dict[str, Any]:
"""Classify a document using rule-based matching.
This task:
1. Loads the :class:`FileRecord` from the database.
2. Gathers filename, OCR text, and existing AI metadata.
3. Loads built-in + user-defined classification rules.
4. Runs the classification engine.
5. Persists the result into ``ai_metadata.classification``.
Args:
file_id: Primary key of the :class:`FileRecord` to classify.
owner_id: Owner identifier for loading user-specific rules.
Returns:
Dict with ``category``, ``confidence``, and ``matched_rules``.
"""
task_id = self.request.id
log_task_progress(
task_id,
STEP_NAME,
"in_progress",
f"Starting classification for file {file_id}",
file_id=file_id,
)
try:
with SessionLocal() as db:
file_record: FileRecord | None = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if file_record is None:
log_task_progress(
task_id,
STEP_NAME,
"failure",
f"FileRecord {file_id} not found",
file_id=file_id,
)
return {"status": "error", "detail": "File not found"}
# Gather inputs
filename = file_record.original_filename or ""
text = file_record.ocr_text or ""
existing_metadata: dict[str, Any] = {}
if file_record.ai_metadata:
try:
existing_metadata = json.loads(file_record.ai_metadata)
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to parse ai_metadata for file %s, starting fresh", file_id)
existing_metadata = {}
# Load custom rules
effective_owner = owner_id or file_record.owner_id
custom_rules = _load_custom_rules(effective_owner)
# Run classification engine
result: ClassificationResult = classify_document(
filename=filename,
text=text,
metadata=existing_metadata,
custom_rules=custom_rules,
)
# Persist result into ai_metadata
classification_data = {
"category": result.category,
"confidence": result.confidence,
"matched_rules": [
{
"rule_name": m.rule_name,
"rule_type": m.rule_type,
"category": m.category,
"confidence": m.confidence,
}
for m in result.matched_rules
],
}
existing_metadata["classification"] = classification_data
# If no document_type was set yet, populate it from the classification
if not existing_metadata.get("document_type"):
from app.utils.classification_rules import BUILTIN_CATEGORIES
existing_metadata["document_type"] = BUILTIN_CATEGORIES.get(
result.category, result.category.replace("_", " ").title()
)
file_record.ai_metadata = json.dumps(existing_metadata, ensure_ascii=False)
db.commit()
log_task_progress(
task_id,
STEP_NAME,
"success",
f"Classified as '{result.category}' with confidence {result.confidence}",
file_id=file_id,
detail=f"Matched {len(result.matched_rules)} rule(s)",
)
return {
"status": "success",
"category": result.category,
"confidence": result.confidence,
"matched_rules": len(result.matched_rules),
}
except Exception as e:
logger.exception("Classification failed for file %s: %s", file_id, e)
log_task_progress(
task_id,
STEP_NAME,
"failure",
f"Classification failed: {e}",
file_id=file_id,
)
raise
+21 -5
View File
@@ -11,7 +11,6 @@ from app.config import settings
from app.tasks.retry_config import UploadTaskWithRetry
from app.utils import log_task_progress
from app.utils.filename_utils import extract_remote_path, get_unique_filename
from app.utils.network import join_url
logger = logging.getLogger(__name__)
@@ -65,11 +64,17 @@ def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_overri
folder_override if folder_override is not None else (getattr(settings, "nextcloud_folder", "") or "")
)
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
full_url = join_url(webdav_url, remote_path)
full_url = f"{webdav_url}/{remote_path}"
# Remove any double slashes (except in http://)
full_url = full_url.replace("://", "$PLACEHOLDER$")
while "//" in full_url:
full_url = full_url.replace("//", "/")
full_url = full_url.replace("$PLACEHOLDER$", "://")
# Function to check if file exists in Nextcloud
def check_exists_in_nextcloud(path):
check_url = join_url(webdav_url, os.path.dirname(path))
check_url = f"{webdav_url}{os.path.dirname(path)}"
try:
response = requests.request(
"PROPFIND",
@@ -86,7 +91,13 @@ def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_overri
# Check for potential file collision and get a unique name if needed
remote_path = get_unique_filename(remote_path, check_exists_in_nextcloud)
full_url = join_url(webdav_url, remote_path)
full_url = f"{webdav_url}/{remote_path}"
# Fix double slashes again
full_url = full_url.replace("://", "$PLACEHOLDER$")
while "//" in full_url:
full_url = full_url.replace("//", "/")
full_url = full_url.replace("$PLACEHOLDER$", "://")
# Create necessary parent folders
parent_dirs = os.path.dirname(remote_path)
@@ -96,7 +107,12 @@ def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_overri
if not folder:
continue
current_path += f"{folder}/"
mkdir_url = join_url(webdav_url, current_path)
mkdir_url = f"{webdav_url}/{current_path}"
# Fix double slashes
mkdir_url = mkdir_url.replace("://", "$PLACEHOLDER$")
while "//" in mkdir_url:
mkdir_url = mkdir_url.replace("//", "/")
mkdir_url = mkdir_url.replace("$PLACEHOLDER$", "://")
requests.request(
"MKCOL",
+9 -1
View File
@@ -68,6 +68,8 @@ IMAGE_MIME_TYPES: set[str] = {
"image/tiff",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
}
# ---------------------------------------------------------------------------
@@ -124,6 +126,8 @@ ALLOWED_EXTENSIONS: set[str] = {
".tif",
".webp",
".svg",
".heic",
".heif",
# Web
".html",
".htm",
@@ -234,7 +238,7 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
},
"images": {
"label": "Images",
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg)",
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg, .heic, .heif)",
"mime_types": frozenset(
{
"image/jpeg",
@@ -245,6 +249,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
"image/tiff",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
}
),
"extensions": frozenset(
@@ -258,6 +264,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
".tif",
".webp",
".svg",
".heic",
".heif",
}
),
},
+188
View File
@@ -0,0 +1,188 @@
"""Automation hook utilities for Zapier / Make.com integration.
Provides helpers to build Zapier-compatible flat payloads, query active
automation hook subscriptions, and fan-out event delivery to all matching
hooks via Celery tasks.
The payload format is intentionally *flat* (no nested ``data`` key) so that
Zapier and Make.com can map fields without JSONPath expressions. An ``id``
field is included for Zapier deduplication.
"""
import json
import logging
import time
import uuid
from typing import Any
from app.config import settings
from app.database import SessionLocal
from app.models import AutomationHook
from app.utils.webhook import VALID_EVENTS
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Payload helpers
# ---------------------------------------------------------------------------
def build_zapier_payload(event: str, data: dict[str, Any]) -> dict[str, Any]:
"""Build a flat, Zapier-compatible webhook payload.
Zapier works best with flat JSON objects that include an ``id`` field
for deduplication. This function merges event metadata into the
top-level object alongside the event-specific *data*.
Args:
event: The event name (e.g. ``document.processed``).
data: Event-specific key/value pairs.
Returns:
A flat dictionary suitable for Zapier / Make.com consumption.
"""
return {
"id": f"evt_{uuid.uuid4().hex[:16]}",
"event": event,
"timestamp": time.time(),
**data,
}
# ---------------------------------------------------------------------------
# Sample payloads (used by the /triggers/sample endpoint)
# ---------------------------------------------------------------------------
#: Example payloads that Zapier uses for field-mapping during Zap creation.
SAMPLE_PAYLOADS: dict[str, dict[str, Any]] = {
"document.uploaded": {
"id": "evt_sample0001",
"event": "document.uploaded",
"timestamp": 1710000000.0,
"document_id": 42,
"filename": "invoice_2024.pdf",
"content_type": "application/pdf",
"size_bytes": 204800,
"owner_id": "user@example.com",
},
"document.processed": {
"id": "evt_sample0002",
"event": "document.processed",
"timestamp": 1710000060.0,
"document_id": 42,
"filename": "invoice_2024.pdf",
"status": "processed",
"title": "Invoice #1234",
"owner_id": "user@example.com",
},
"document.failed": {
"id": "evt_sample0003",
"event": "document.failed",
"timestamp": 1710000120.0,
"document_id": 42,
"filename": "corrupt.pdf",
"status": "failed",
"error": "Unable to extract text from document",
"owner_id": "user@example.com",
},
"user.signup": {
"id": "evt_sample0004",
"event": "user.signup",
"timestamp": 1710000180.0,
"user_id": "newuser@example.com",
"display_name": "Jane Doe",
},
"user.plan_changed": {
"id": "evt_sample0005",
"event": "user.plan_changed",
"timestamp": 1710000240.0,
"user_id": "user@example.com",
"old_tier": "free",
"new_tier": "pro",
},
"user.payment_issue": {
"id": "evt_sample0006",
"event": "user.payment_issue",
"timestamp": 1710000300.0,
"user_id": "user@example.com",
"issue": "Credit card declined",
},
}
# ---------------------------------------------------------------------------
# Database queries
# ---------------------------------------------------------------------------
def get_active_hooks_for_event(event: str) -> list[dict[str, Any]]:
"""Return all active automation hooks subscribed to *event*.
Args:
event: The event name to filter on.
Returns:
A list of dicts with ``id``, ``target_url``, ``secret``, and
``events`` keys.
"""
db = SessionLocal()
try:
hooks = db.query(AutomationHook).filter(AutomationHook.is_active.is_(True)).all()
result: list[dict[str, Any]] = []
for hook in hooks:
try:
subscribed = json.loads(hook.events)
except (json.JSONDecodeError, TypeError):
subscribed = []
if event in subscribed:
result.append(
{
"id": hook.id,
"target_url": hook.target_url,
"secret": hook.secret,
"events": subscribed,
}
)
return result
finally:
db.close()
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
def dispatch_automation_hooks(event: str, data: dict[str, Any]) -> None:
"""Fan-out an event to all matching active automation hooks.
Builds a Zapier-compatible flat payload and queues a Celery task for
each matching hook so delivery is asynchronous with automatic retries.
Args:
event: Event name (must be in :data:`VALID_EVENTS`).
data: Event-specific payload data.
"""
if not settings.automation_hooks_enabled:
return
if event not in VALID_EVENTS:
logger.warning("Ignoring unknown automation hook event: %s", event)
return
hooks = get_active_hooks_for_event(event)
if not hooks:
logger.debug("No active automation hooks for event %s", event)
return
payload = build_zapier_payload(event, data)
from app.tasks.automation_tasks import deliver_automation_hook_task
for hook in hooks:
try:
deliver_automation_hook_task.delay(hook["target_url"], payload, hook["secret"])
logger.debug("Queued automation hook delivery to %s for event %s", hook["target_url"], event)
except Exception as exc:
logger.error("Failed to queue automation hook to %s: %s", hook["target_url"], exc)
+378
View File
@@ -0,0 +1,378 @@
"""
Rule-based document classification engine.
Provides pre-built categories and a rule matcher that classifies documents
using filename patterns, content keywords, and metadata fields. Custom
rules stored in the database are evaluated alongside the built-in defaults.
Usage::
from app.utils.classification_rules import classify_document
result = classify_document(
filename="2024-03-01_Invoice_Acme.pdf",
text="Invoice total: $1,234.56",
metadata={"absender": "Acme Corp"},
custom_rules=custom_rules_from_db,
)
# result -> ClassificationResult(category="invoice", confidence=85, matched_rules=[...])
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pre-built categories
# ---------------------------------------------------------------------------
#: Canonical category names recognized by the system. Users may also define
#: their own categories via custom rules.
BUILTIN_CATEGORIES: dict[str, str] = {
"invoice": "Invoice",
"contract": "Contract",
"receipt": "Receipt",
"letter": "Letter",
"report": "Report",
"bank_statement": "Bank Statement",
"tax_document": "Tax Document",
"insurance": "Insurance Document",
"payslip": "Payslip",
"unknown": "Unknown",
}
# ---------------------------------------------------------------------------
# Rule type constants
# ---------------------------------------------------------------------------
RULE_TYPE_FILENAME = "filename_pattern"
RULE_TYPE_CONTENT = "content_keyword"
RULE_TYPE_METADATA = "metadata_match"
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ClassificationRule:
"""A single classification rule."""
name: str
category: str
rule_type: str # filename_pattern | content_keyword | metadata_match
pattern: str # regex for filename, keyword(s) for content, "field=value" for metadata
priority: int = 0 # higher = evaluated first
case_sensitive: bool = False
def __post_init__(self) -> None:
if self.rule_type not in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA):
raise ValueError(f"Invalid rule_type: {self.rule_type!r}")
@dataclass
class MatchedRule:
"""Records which rule matched and why."""
rule_name: str
rule_type: str
category: str
confidence: int
@dataclass
class ClassificationResult:
"""The outcome of running the classification engine on a document."""
category: str
confidence: int # 0 100
matched_rules: list[MatchedRule] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Built-in rules
# ---------------------------------------------------------------------------
BUILTIN_RULES: list[ClassificationRule] = [
# ── Invoice ───────────────────────────────────────────────────────────
ClassificationRule("builtin_invoice_filename", "invoice", RULE_TYPE_FILENAME, r"(?i)invoice|rechnung|facture"),
ClassificationRule(
"builtin_invoice_content",
"invoice",
RULE_TYPE_CONTENT,
"invoice number|invoice total|amount due|rechnung|rechnungsnummer|total amount|bill to",
),
ClassificationRule("builtin_invoice_metadata", "invoice", RULE_TYPE_METADATA, "document_type=Invoice"),
ClassificationRule(
"builtin_invoice_kommunikationsart", "invoice", RULE_TYPE_METADATA, "kommunikationsart=Rechnung"
),
# ── Contract ──────────────────────────────────────────────────────────
ClassificationRule("builtin_contract_filename", "contract", RULE_TYPE_FILENAME, r"(?i)contract|vertrag|agreement"),
ClassificationRule(
"builtin_contract_content",
"contract",
RULE_TYPE_CONTENT,
"hereby agrees|terms and conditions|vertrag|agreement between|party agrees|effective date",
),
ClassificationRule("builtin_contract_metadata", "contract", RULE_TYPE_METADATA, "document_type=Contract"),
ClassificationRule(
"builtin_contract_kommunikationsart", "contract", RULE_TYPE_METADATA, "kommunikationsart=Vertrag"
),
# ── Receipt ───────────────────────────────────────────────────────────
ClassificationRule("builtin_receipt_filename", "receipt", RULE_TYPE_FILENAME, r"(?i)receipt|quittung|beleg"),
ClassificationRule(
"builtin_receipt_content",
"receipt",
RULE_TYPE_CONTENT,
"receipt|quittung|payment received|thank you for your purchase|transaction id",
),
ClassificationRule("builtin_receipt_metadata", "receipt", RULE_TYPE_METADATA, "document_type=Receipt"),
ClassificationRule(
"builtin_receipt_kommunikationsart", "receipt", RULE_TYPE_METADATA, "kommunikationsart=Quittung"
),
# ── Letter ────────────────────────────────────────────────────────────
ClassificationRule("builtin_letter_filename", "letter", RULE_TYPE_FILENAME, r"(?i)letter|brief|schreiben"),
ClassificationRule(
"builtin_letter_content",
"letter",
RULE_TYPE_CONTENT,
"dear sir|dear madam|sehr geehrte|to whom it may concern|sincerely|mit freundlichen",
),
# ── Report ────────────────────────────────────────────────────────────
ClassificationRule("builtin_report_filename", "report", RULE_TYPE_FILENAME, r"(?i)report|bericht"),
ClassificationRule(
"builtin_report_content",
"report",
RULE_TYPE_CONTENT,
"executive summary|table of contents|annual report|quarterly report|findings",
),
# ── Bank statement ────────────────────────────────────────────────────
ClassificationRule(
"builtin_bank_filename",
"bank_statement",
RULE_TYPE_FILENAME,
r"(?i)bank.?statement|kontoauszug",
),
ClassificationRule(
"builtin_bank_content",
"bank_statement",
RULE_TYPE_CONTENT,
"account statement|kontoauszug|opening balance|closing balance|account number",
),
ClassificationRule(
"builtin_bank_kommunikationsart", "bank_statement", RULE_TYPE_METADATA, "kommunikationsart=Kontoauszug"
),
# ── Tax document ──────────────────────────────────────────────────────
ClassificationRule("builtin_tax_filename", "tax_document", RULE_TYPE_FILENAME, r"(?i)tax|steuer|steuerbescheid"),
ClassificationRule(
"builtin_tax_content",
"tax_document",
RULE_TYPE_CONTENT,
"tax return|steuerbescheid|taxable income|finanzamt|tax assessment",
),
# ── Insurance ─────────────────────────────────────────────────────────
ClassificationRule(
"builtin_insurance_filename", "insurance", RULE_TYPE_FILENAME, r"(?i)insurance|versicherung|police"
),
ClassificationRule(
"builtin_insurance_content",
"insurance",
RULE_TYPE_CONTENT,
"insurance policy|versicherung|policennummer|coverage|premium|deductible",
),
# ── Payslip ───────────────────────────────────────────────────────────
ClassificationRule(
"builtin_payslip_filename", "payslip", RULE_TYPE_FILENAME, r"(?i)payslip|gehaltsabrechnung|lohnabrechnung"
),
ClassificationRule(
"builtin_payslip_content",
"payslip",
RULE_TYPE_CONTENT,
"gross salary|net salary|gehaltsabrechnung|lohnabrechnung|bruttolohn|nettolohn",
),
]
# ---------------------------------------------------------------------------
# Confidence scoring
# ---------------------------------------------------------------------------
#: Base confidence for each rule type when it matches.
_CONFIDENCE_MAP: dict[str, int] = {
RULE_TYPE_FILENAME: 60,
RULE_TYPE_CONTENT: 70,
RULE_TYPE_METADATA: 90,
}
#: Extra confidence per additional matching rule of the same category (capped).
_CONFIDENCE_BONUS_PER_EXTRA_RULE = 10
# ---------------------------------------------------------------------------
# Matching helpers
# ---------------------------------------------------------------------------
def _match_filename(rule: ClassificationRule, filename: str) -> bool:
"""Return True if *rule.pattern* (regex) matches anywhere in *filename*."""
if not filename:
return False
flags = 0 if rule.case_sensitive else re.IGNORECASE
return bool(re.search(rule.pattern, filename, flags))
def _match_content(rule: ClassificationRule, text: str) -> bool:
"""Return True if any keyword in *rule.pattern* appears in *text*.
Keywords are separated by ``|`` (pipe).
"""
if not text:
return False
keywords = [kw.strip() for kw in rule.pattern.split("|") if kw.strip()]
text_lower = text if rule.case_sensitive else text.lower()
return any((kw if rule.case_sensitive else kw.lower()) in text_lower for kw in keywords)
def _match_metadata(rule: ClassificationRule, metadata: dict[str, Any] | None) -> bool:
"""Return True if *rule.pattern* (``field=value``) matches *metadata*.
Pattern format: ``field_name=expected_value``.
"""
if not metadata:
return False
if "=" not in rule.pattern:
return False
field_name, expected_value = rule.pattern.split("=", 1)
actual = metadata.get(field_name.strip())
if actual is None:
return False
if rule.case_sensitive:
return str(actual) == expected_value.strip()
return str(actual).lower() == expected_value.strip().lower()
_MATCHERS: dict[str, tuple] = {
RULE_TYPE_FILENAME: (_match_filename, "filename"),
RULE_TYPE_CONTENT: (_match_content, "text"),
RULE_TYPE_METADATA: (_match_metadata, "metadata"),
}
def _evaluate_rule(
rule: ClassificationRule,
filename: str,
text: str,
metadata: dict[str, Any] | None,
) -> MatchedRule | None:
"""Evaluate a single rule against the document. Return a :class:`MatchedRule` on match."""
entry = _MATCHERS.get(rule.rule_type)
if entry is None:
return None
matcher, arg_key = entry
arg_map = {"filename": filename, "text": text, "metadata": metadata}
matched = matcher(rule, arg_map[arg_key])
if matched:
return MatchedRule(
rule_name=rule.name,
rule_type=rule.rule_type,
category=rule.category,
confidence=_CONFIDENCE_MAP.get(rule.rule_type, 50),
)
return None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def classify_document(
filename: str = "",
text: str = "",
metadata: dict[str, Any] | None = None,
custom_rules: list[ClassificationRule] | None = None,
) -> ClassificationResult:
"""Classify a document by evaluating built-in and custom rules.
Rules are evaluated in priority order (highest first, then built-in before
custom for the same priority). The category with the most rule matches
wins; ties are broken by cumulative confidence.
Args:
filename: Original filename of the document.
text: Extracted / OCR text of the document.
metadata: Previously-extracted AI metadata dict (e.g. from ``ai_metadata``).
custom_rules: Optional list of user-defined :class:`ClassificationRule` objects.
Returns:
A :class:`ClassificationResult` with the best matching category,
overall confidence score, and the list of rules that fired.
"""
all_rules = list(BUILTIN_RULES)
if custom_rules:
all_rules.extend(custom_rules)
# Sort by priority descending (higher priority first)
all_rules.sort(key=lambda r: r.priority, reverse=True)
matches: list[MatchedRule] = []
for rule in all_rules:
result = _evaluate_rule(rule, filename, text, metadata)
if result is not None:
matches.append(result)
if not matches:
return ClassificationResult(category="unknown", confidence=0, matched_rules=[])
# Aggregate by category: pick the one with the most matches, then highest
# cumulative confidence as tiebreaker.
category_scores: dict[str, list[MatchedRule]] = {}
for m in matches:
category_scores.setdefault(m.category, []).append(m)
best_category = max(
category_scores,
key=lambda cat: (len(category_scores[cat]), sum(m.confidence for m in category_scores[cat])),
)
best_matches = category_scores[best_category]
base_confidence = max(m.confidence for m in best_matches)
bonus = min(
(len(best_matches) - 1) * _CONFIDENCE_BONUS_PER_EXTRA_RULE,
100 - base_confidence,
)
final_confidence = min(base_confidence + bonus, 100)
return ClassificationResult(
category=best_category,
confidence=final_confidence,
matched_rules=best_matches,
)
def db_rule_to_engine_rule(db_rule: Any) -> ClassificationRule:
"""Convert a database ``ClassificationRuleModel`` row to an engine :class:`ClassificationRule`.
Args:
db_rule: A SQLAlchemy model instance with ``name``, ``category``,
``rule_type``, ``pattern``, ``priority``, and ``case_sensitive`` attributes.
Returns:
A :class:`ClassificationRule` dataclass instance.
"""
return ClassificationRule(
name=db_rule.name,
category=db_rule.category,
rule_type=db_rule.rule_type,
pattern=db_rule.pattern,
priority=db_rule.priority,
case_sensitive=getattr(db_rule, "case_sensitive", False),
)
+377 -103
View File
@@ -8,7 +8,6 @@ This module provides functionality to:
"""
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.exc import SQLAlchemyError
@@ -40,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",
@@ -56,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",
@@ -151,6 +206,14 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"qr_login_enabled": {
"category": "Authentication",
"description": "Enable QR code-based login for mobile device authentication.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"qr_login_challenge_ttl_seconds": {
"category": "Authentication",
"description": "Time-to-live in seconds for QR login challenges (default 120).",
@@ -207,6 +270,17 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"sso_auto_login": {
"category": "Authentication",
"description": (
"Automatically redirect to SSO login when authentication is required. "
"Skips the login page and sends users directly to the configured SSO provider."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Social Login Providers
"social_auth_google_enabled": {
"category": "Social Login",
@@ -237,6 +311,20 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"social_auth_google_use_global_credentials": {
"category": "Social Login",
"description": (
"When True, Google social login uses the global GOOGLE_DRIVE_CLIENT_ID / "
"GOOGLE_DRIVE_CLIENT_SECRET credentials (the Google Drive OAuth integration) "
"instead of requiring separate SOCIAL_AUTH_GOOGLE_CLIENT_ID / "
"SOCIAL_AUTH_GOOGLE_CLIENT_SECRET values. "
"Requires SOCIAL_AUTH_GOOGLE_ENABLED=True and global Google Drive OAuth credentials to be set."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_microsoft_enabled": {
"category": "Social Login",
"description": (
@@ -279,6 +367,20 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"social_auth_microsoft_use_global_credentials": {
"category": "Social Login",
"description": (
"When True, Microsoft social login uses the global ONEDRIVE_CLIENT_ID / "
"ONEDRIVE_CLIENT_SECRET credentials (the OneDrive integration credentials) "
"instead of requiring separate SOCIAL_AUTH_MICROSOFT_CLIENT_ID / "
"SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET values. "
"Requires SOCIAL_AUTH_MICROSOFT_ENABLED=True and global OneDrive credentials to be set."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_apple_enabled": {
"category": "Social Login",
"description": (
@@ -327,6 +429,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": (
@@ -354,6 +469,182 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"social_auth_github_enabled": {
"category": "Social Login",
"description": (
"Enable GitHub Sign-In. Requires SOCIAL_AUTH_GITHUB_CLIENT_ID and "
"SOCIAL_AUTH_GITHUB_CLIENT_SECRET from GitHub Developer Settings."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
"help_link": "https://github.com/settings/developers",
"help_link_label": "GitHub Developer Settings",
},
"social_auth_github_client_id": {
"category": "Social Login",
"description": "GitHub OAuth2 client ID from GitHub Developer Settings.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_github_client_secret": {
"category": "Social Login",
"description": "GitHub OAuth2 client secret from GitHub Developer Settings.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": True,
},
# Keycloak SSO
"social_auth_keycloak_enabled": {
"category": "Social Login",
"description": "Enable Keycloak SSO. Requires server URL, realm, client ID, and client secret.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_keycloak_client_id": {
"category": "Social Login",
"description": "Keycloak OAuth2 client ID.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_keycloak_client_secret": {
"category": "Social Login",
"description": "Keycloak OAuth2 client secret.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": True,
},
"social_auth_keycloak_server_url": {
"category": "Social Login",
"description": "Keycloak server base URL (e.g. https://keycloak.example.com).",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_keycloak_realm": {
"category": "Social Login",
"description": "Keycloak realm name.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
# Generic OAuth2 SSO
"social_auth_generic_oauth2_enabled": {
"category": "Social Login",
"description": "Enable a generic OAuth2 SSO provider.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_generic_oauth2_client_id": {
"category": "Social Login",
"description": "Generic OAuth2 client ID.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_generic_oauth2_client_secret": {
"category": "Social Login",
"description": "Generic OAuth2 client secret.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": True,
},
"social_auth_generic_oauth2_authorize_url": {
"category": "Social Login",
"description": "Generic OAuth2 authorization URL.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_generic_oauth2_token_url": {
"category": "Social Login",
"description": "Generic OAuth2 token endpoint URL.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_generic_oauth2_userinfo_url": {
"category": "Social Login",
"description": "Generic OAuth2 userinfo endpoint URL.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_generic_oauth2_scope": {
"category": "Social Login",
"description": "Space-separated list of OAuth2 scopes to request (default: openid profile email).",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_generic_oauth2_name": {
"category": "Social Login",
"description": "Display name for the generic OAuth2 provider button on the login page.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
# SAML2 SSO
"social_auth_saml2_enabled": {
"category": "Social Login",
"description": "Enable SAML2 SSO authentication.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_saml2_entity_id": {
"category": "Social Login",
"description": "SAML2 Identity Provider Entity ID.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_saml2_sso_url": {
"category": "Social Login",
"description": "SAML2 Identity Provider SSO URL.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"social_auth_saml2_certificate": {
"category": "Social Login",
"description": "SAML2 Identity Provider X.509 certificate (PEM format).",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": True,
},
"social_auth_saml2_name": {
"category": "Social Login",
"description": "Display name for the SAML2 provider.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
# AI Services
"openai_api_key": {
"category": "AI Services",
@@ -720,6 +1011,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",
@@ -1872,6 +2175,30 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"telegram_enabled": {
"category": "Notifications",
"description": "Enable Telegram bot notifications.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"telegram_bot_token": {
"category": "Notifications",
"description": "Telegram Bot API token from @BotFather.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": False,
},
"telegram_chat_id": {
"category": "Notifications",
"description": "Telegram chat ID to send notifications to.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Notifications Settings
"notification_urls": {
"category": "Notifications",
@@ -1970,6 +2297,18 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"automation_hooks_enabled": {
"category": "Feature Flags",
"description": (
"Enable Zapier / Make.com automation hook subscriptions and delivery. "
"When enabled, external automation platforms can subscribe to DocuElevate events "
"via the REST hooks protocol. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"compliance_enabled": {
"category": "Feature Flags",
"description": (
@@ -2558,51 +2897,6 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
# Database Connection Pool
"db_pool_size": {
"category": "Core",
"description": (
"Number of persistent connections kept in the SQLAlchemy QueuePool. "
"Has no effect for SQLite databases. Default: 5."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"db_max_overflow": {
"category": "Core",
"description": (
"Maximum extra connections that can be opened beyond db_pool_size. "
"Has no effect for SQLite databases. Default: 10."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"db_pool_timeout": {
"category": "Core",
"description": (
"Seconds to wait for a connection from the pool before raising an error. "
"Has no effect for SQLite databases. Default: 30."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"db_pool_recycle": {
"category": "Core",
"description": (
"Seconds after which idle connections are recycled to prevent stale connections. "
"Has no effect for SQLite databases. Default: 1800 (30 minutes)."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
# Per-user upload rate limiting
"upload_rate_limit_per_user": {
"category": "Security",
@@ -2938,6 +3232,43 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"sentry_js_traces_sample_rate": {
"category": "Observability",
"description": (
"Fraction of browser page-loads captured for client-side Sentry performance tracing (0.01.0). "
"0.0 (default) disables browser tracing; 1.0 captures every navigation. "
"Only active when SENTRY_DSN is set."
),
"type": "float",
"sensitive": False,
"required": False,
"restart_required": True,
},
"sentry_js_replay_session_sample_rate": {
"category": "Observability",
"description": (
"Fraction of sessions recorded by Sentry Session Replay (0.01.0). "
"0.0 (default) disables session recording; 1.0 records every session. "
"Only active when SENTRY_DSN is set."
),
"type": "float",
"sensitive": False,
"required": False,
"restart_required": True,
},
"sentry_js_replay_on_error_sample_rate": {
"category": "Observability",
"description": (
"Fraction of error sessions recorded by Sentry Session Replay (0.01.0). "
"Defaults to 0.1 (10%) so that errors are captured with replay context "
"even when session-level recording is disabled. "
"Only active when SENTRY_DSN is set."
),
"type": "float",
"sensitive": False,
"required": False,
"restart_required": True,
},
}
@@ -3372,60 +3703,3 @@ def get_settings_for_export(db: Session, source: str = "db") -> Dict[str, str]:
# DB only
db_settings = get_all_settings_from_db(db)
return {k.upper(): v for k, v in sorted(db_settings.items()) if v is not None}
def update_env_file(env_path: str, settings_to_update: dict[str, str]) -> bool:
"""
Update an .env file with new settings.
Reads the file, updates matching settings (even if commented),
appends any that weren't found, and writes the result back.
Args:
env_path: Path to the .env file
settings_to_update: Dictionary mapping setting names (e.g. 'GOOGLE_DRIVE_USE_OAUTH') to string values
Returns:
True if the file was successfully updated, False otherwise (e.g. file not found or write error)
"""
try:
if not os.path.exists(env_path):
logger.warning(f".env file not found at {env_path}, skipping file update")
return False
logger.info(f"Updating settings in {env_path}")
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in settings_to_update.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
# Uncomment if commented out - check the original stripped line
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
# Add any settings that weren't updated (they weren't in the file)
for key, value in settings_to_update.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Write the updated .env file
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info(f"Successfully updated settings in {env_path}")
return True
except Exception as e:
logger.warning(f"Failed to update {env_path}: {str(e)}")
return False
+10
View File
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
except Exception as exc:
logger.warning(f"Could not reload in-process settings: {exc}")
# Re-register OAuth / social-login providers so that any provider whose
# credentials were just saved (or updated) in the database is active
# immediately on the login page — no restart required.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as exc:
logger.warning(f"Could not refresh social login providers after settings update: {exc}")
# Re-check OCR language availability in the background whenever settings
# are updated. This ensures that if a user changes tesseract_language or
# easyocr_languages via the UI, the new language data is downloaded without
+91 -5
View File
@@ -11,14 +11,21 @@ import logging
from fastapi import Request
from sqlalchemy import or_
from sqlalchemy.orm import Query
from sqlalchemy.orm import Query, Session
from sqlalchemy.sql import false
from app.config import settings
from app.models import FileRecord
from app.models import FILE_SHARE_ROLE_EDITOR, FILE_SHARE_ROLE_VIEWER, FileRecord, FileShare
logger = logging.getLogger(__name__)
# Role hierarchy: higher index = more rights
_ROLE_RANK: dict[str, int] = {
FILE_SHARE_ROLE_VIEWER: 1,
FILE_SHARE_ROLE_EDITOR: 2,
"owner": 3,
}
def _owner_id_from_user(user: dict) -> str | None:
"""Extract the owner identifier from a user dict.
@@ -93,8 +100,9 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
"""Conditionally filter a ``FileRecord`` query by the current user.
When multi-user mode is enabled, only files whose ``owner_id``
matches the authenticated user are returned. Admin users bypass
the filter and see all documents.
matches the authenticated user are returned, **plus** any files that
have been explicitly shared with the user via ``FileShare``. Admin
users bypass the filter and see all documents.
When ``unowned_docs_visible_to_all`` is ``True`` (default), documents
with ``owner_id IS NULL`` (unclaimed) are also included for every
@@ -122,11 +130,89 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
# No authenticated user — return empty result set
return query.filter(false())
# Build filter: user's own documents
# Build filter: user's own documents + documents shared with them
conditions = [FileRecord.owner_id == owner_id]
# Include files explicitly shared with this user
from sqlalchemy import select as sa_select
conditions.append(FileRecord.id.in_(sa_select(FileShare.file_id).where(FileShare.shared_with_user_id == owner_id)))
# Optionally include unclaimed (owner_id IS NULL) documents
if settings.unowned_docs_visible_to_all:
conditions.append(FileRecord.owner_id.is_(None))
return query.filter(or_(*conditions))
def get_file_role(file_record: FileRecord, user_id: str | None, db: Session) -> str | None:
"""Return the effective role a user has on a ``FileRecord``.
Roles (in descending order of privilege):
``"owner"`` the user's ``owner_id`` matches ``file_record.owner_id``,
or multi-user mode is disabled (everyone is effectively an
owner in single-user mode).
``"editor"`` the user has an explicit ``FileShare`` with role=editor.
``"viewer"`` the user has an explicit ``FileShare`` with role=viewer,
or the file is unclaimed (``owner_id IS NULL``) and
``unowned_docs_visible_to_all`` is True.
``None`` no access.
Args:
file_record: The ``FileRecord`` to check.
user_id: The stable identifier of the requesting user.
db: An active SQLAlchemy session.
Returns:
One of ``"owner"``, ``"editor"``, ``"viewer"``, or ``None``.
"""
if not settings.multi_user_enabled:
# Single-user mode: full access for everyone
return "owner"
if user_id is None:
return None
# Owner always has full access
if file_record.owner_id == user_id:
return "owner"
# Unclaimed document — limited access when setting allows it
if file_record.owner_id is None and settings.unowned_docs_visible_to_all:
return FILE_SHARE_ROLE_VIEWER
# Check for an explicit share
share = (
db.query(FileShare)
.filter(FileShare.file_id == file_record.id, FileShare.shared_with_user_id == user_id)
.first()
)
if share:
return share.role
return None
def has_file_role(
file_record: FileRecord,
user_id: str | None,
db: Session,
minimum_role: str = FILE_SHARE_ROLE_VIEWER,
) -> bool:
"""Return ``True`` if the user's effective role meets the minimum required.
Args:
file_record: The document to check.
user_id: Requesting user's stable identifier.
db: Active SQLAlchemy session.
minimum_role: The minimum role required (``"viewer"``, ``"editor"``,
or ``"owner"``).
Returns:
``True`` when the user's role rank is >= the minimum rank.
"""
role = get_file_role(file_record, user_id, db)
if role is None:
return False
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK.get(minimum_role, 0)
+11 -2
View File
@@ -145,6 +145,8 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
It delegates to :func:`deliver_webhook_task` (Celery) for each matching
webhook so delivery happens asynchronously with automatic retries.
Also dispatches to automation hooks (Zapier / Make.com) if enabled.
Args:
event: Event name (must be in :data:`VALID_EVENTS`).
data: Event-specific payload data.
@@ -156,8 +158,7 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
webhooks = get_active_webhooks_for_event(event)
if not webhooks:
logger.debug("No active webhooks for event %s", event)
return
else:
payload = build_payload(event, data)
# Import here to avoid circular dependency with celery_app
@@ -169,3 +170,11 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
except Exception as exc:
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
# Also fan-out to Zapier / Make.com automation hooks
try:
from app.utils.automation_hooks import dispatch_automation_hooks
dispatch_automation_hooks(event, data)
except Exception as exc:
logger.error("Failed to dispatch automation hooks for event %s: %s", event, exc)
+15
View File
@@ -96,6 +96,21 @@ def _inject_global_context(ctx: dict) -> None:
)
ctx.setdefault("enable_factory_reset", getattr(settings, "enable_factory_reset", False))
# Sentry Browser SDK config (injected into every page so the JS SDK can initialise)
# Normalize empty-string DSN to None so the {% if sentry_dsn %} template guard works correctly.
_raw_dsn = getattr(settings, "sentry_dsn", None)
ctx.setdefault("sentry_dsn", _raw_dsn if _raw_dsn else None)
ctx.setdefault("sentry_environment", getattr(settings, "sentry_environment", "production"))
ctx.setdefault("sentry_js_traces_sample_rate", getattr(settings, "sentry_js_traces_sample_rate", 0.0))
ctx.setdefault(
"sentry_js_replay_session_sample_rate",
getattr(settings, "sentry_js_replay_session_sample_rate", 0.0),
)
ctx.setdefault(
"sentry_js_replay_on_error_sample_rate",
getattr(settings, "sentry_js_replay_on_error_sample_rate", 0.1),
)
req = ctx.get("request")
if req is not None:
# CSRF token
+27 -1
View File
@@ -14,6 +14,19 @@ from app.views.base import APIRouter, Depends, get_db, require_login, settings,
router = APIRouter()
def _get_dropbox_callback_url(request: Request) -> str:
"""Return the Dropbox OAuth callback URL.
Uses ``PUBLIC_BASE_URL`` when configured so that the redirect URI displayed
to the user (and registered in the Dropbox developer console) matches the
one used in the OAuth authorization request. Falls back to deriving the URL
from the incoming request when ``PUBLIC_BASE_URL`` is not set.
"""
if settings.public_base_url:
return settings.public_base_url.rstrip("/") + "/dropbox-callback"
return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback"
@router.get("/dropbox-setup")
@require_login
async def dropbox_setup_page(
@@ -30,6 +43,8 @@ async def dropbox_setup_page(
path from the integration's existing config is pre-populated; global
admin credentials are never exposed in this mode.
"""
callback_url = _get_dropbox_callback_url(request)
if integration_id is not None:
owner_id = get_current_owner_id(request)
integration = (
@@ -46,6 +61,12 @@ async def dropbox_setup_page(
cfg = {}
# Support both "folder" (DROPBOX destination) and "folder_path" (WATCH_FOLDER source)
folder_path = cfg.get("folder", cfg.get("folder_path", ""))
# Determine if global credentials are available for users to reuse
global_creds_available = bool(
settings.dropbox_allow_global_credentials_for_integrations
and settings.dropbox_app_key
and settings.dropbox_app_secret
)
return templates.TemplateResponse(
"dropbox.html",
{
@@ -56,9 +77,12 @@ async def dropbox_setup_page(
"integration_name": integration.name,
"integration_type": integration.integration_type,
"folder_path": folder_path,
"app_key_value": "",
# Only expose the public app key (not the secret) when global creds are allowed
"app_key_value": settings.dropbox_app_key if global_creds_available else "",
"app_secret_value": "",
"refresh_token_value": "",
"global_creds_available": global_creds_available,
"callback_url": callback_url,
},
)
@@ -78,6 +102,7 @@ async def dropbox_setup_page(
"integration_id": integration_id,
"integration_name": None,
"integration_type": None,
"callback_url": callback_url,
},
)
@@ -108,5 +133,6 @@ async def dropbox_callback(request: Request, code: str = None, error: str = None
"app_key_value": "", # The callback will prioritize sessionStorage values
"app_secret_value": "", # The callback will prioritize sessionStorage values
"folder_path": "", # The callback will prioritize sessionStorage values
"callback_url": _get_dropbox_callback_url(request),
},
)
+198 -7
View File
@@ -19,6 +19,43 @@ router = APIRouter()
_FILE_NOT_FOUND = "File not found"
def _resolve_owner_context(request: Request, file_record, db: Session) -> dict:
"""Return owner display info and the current user's effective role.
Returns a dict with:
- ``current_user_role``: one of "owner" / "editor" / "viewer" / None
- ``owner_display``: human-readable owner string (display_name or user_id)
- ``multi_user_enabled``: whether multi-user mode is active
"""
from app.config import settings
from app.models import UserProfile
from app.utils.user_scope import get_current_owner_id, get_file_role
multi_user_enabled = settings.multi_user_enabled
current_owner_id = get_current_owner_id(request)
user_session = request.session.get("user")
is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
if is_admin:
current_user_role: str | None = "owner"
else:
current_user_role = get_file_role(file_record, current_owner_id, db)
# Build a human-readable owner label
if file_record.owner_id:
profile = db.query(UserProfile).filter(UserProfile.user_id == file_record.owner_id).first()
owner_display: str | None = profile.display_name if profile and profile.display_name else file_record.owner_id
else:
owner_display = None # No owner (unowned)
return {
"current_user_role": current_user_role,
"owner_display": owner_display,
"multi_user_enabled": multi_user_enabled,
}
@router.get("/files")
@require_login
def files_page(
@@ -206,10 +243,93 @@ def files_page(
@router.get("/files/{file_id}")
@require_login
def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the file summary page a concise overview with links to detail, processing, and annotations views.
"""
try:
import json
import os
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
return templates.TemplateResponse(
"file_summary.html",
{"request": request, "file": None, "error": f"File with ID {file_id} not found"},
)
from app.config import settings
workdir = os.path.realpath(settings.workdir)
def _safe_exists(path: str | None) -> bool:
"""Return True only when *path* exists and resides within workdir."""
if not path:
return False
resolved = os.path.realpath(path)
try:
common = os.path.commonpath([resolved, workdir])
except ValueError:
return False
return common == workdir and os.path.exists(resolved)
original_file_exists = _safe_exists(file_record.original_file_path)
processed_file_exists = _safe_exists(file_record.processed_file_path)
# Load AI metadata — JSON sidecar file first, then DB column
gpt_metadata = None
if file_record.processed_file_path:
metadata_path = os.path.splitext(os.path.realpath(file_record.processed_file_path))[0] + ".json"
if _safe_exists(metadata_path):
try:
with open(metadata_path, "r", encoding="utf-8") as f:
gpt_metadata = json.load(f)
except Exception as e:
logger.warning(f"Failed to load metadata sidecar for file {file_id}: {e}")
if gpt_metadata is None and file_record.ai_metadata:
try:
gpt_metadata = json.loads(file_record.ai_metadata)
except Exception as e:
logger.warning(f"Failed to parse ai_metadata for file {file_id}: {e}")
# Quick processing status
try:
from app.utils.step_manager import get_step_summary as _get_step_summary
step_summary = _get_step_summary(db, file_id)
except Exception:
step_summary = None
pipeline_info = _resolve_pipeline(db, file_record)
owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_summary.html",
{
"request": request,
"file": file_record,
"gpt_metadata": gpt_metadata,
"original_file_exists": original_file_exists,
"processed_file_exists": processed_file_exists,
"step_summary": step_summary,
"pipeline_info": pipeline_info,
**owner_ctx,
},
)
except Exception as e:
logger.error(f"Error retrieving file summary {file_id}: {str(e)}")
return templates.TemplateResponse("file_summary.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/detail")
@require_login
def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the document view page document-centric view with metadata, preview, and extracted text.
Process-oriented details are available via /files/{file_id}/detail.
Return the document detail page document-centric view with metadata, preview, and extracted text.
"""
try:
import json
@@ -272,6 +392,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
# Resolve the pipeline assigned to this file (explicit or system default)
pipeline_info = _resolve_pipeline(db, file_record)
owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_view.html",
@@ -283,6 +404,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
"processed_file_exists": processed_file_exists,
"step_summary": step_summary,
"pipeline_info": pipeline_info,
**owner_ctx,
},
)
except Exception as e:
@@ -290,11 +412,11 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
return templates.TemplateResponse("file_view.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/detail")
@router.get("/files/{file_id}/process")
@require_login
def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the file detail page showing processing history and file information
Return the file processing page showing processing history and pipeline information.
"""
try:
import json
@@ -375,6 +497,77 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d
return templates.TemplateResponse("file_detail.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/annotations")
@require_login
def file_annotations_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the comments & annotations page for a file.
"""
try:
import os
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
return templates.TemplateResponse(
"file_annotations.html",
{"request": request, "file": None, "error": f"File with ID {file_id} not found"},
)
from app.config import settings
workdir = os.path.realpath(settings.workdir)
def _safe_exists(path: str | None) -> bool:
"""Return True only when *path* exists and resides within workdir."""
if not path:
return False
resolved = os.path.realpath(path)
try:
common = os.path.commonpath([resolved, workdir])
except ValueError:
return False
return common == workdir and os.path.exists(resolved)
original_file_exists = _safe_exists(file_record.original_file_path)
processed_file_exists = _safe_exists(file_record.processed_file_path)
# Determine whether the file is a PDF (for EmbedPDF viewer)
mime = file_record.mime_type or ""
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
# Determine the current user's role on this file (and owner display info)
owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_annotations.html",
{
"request": request,
"file": file_record,
"original_file_exists": original_file_exists,
"processed_file_exists": processed_file_exists,
"is_pdf": is_pdf,
**owner_ctx,
},
)
except Exception as e:
logger.error(f"Error retrieving annotations for file {file_id}: {str(e)}")
return templates.TemplateResponse("file_annotations.html", {"request": request, "file": None, "error": str(e)})
@router.get("/files/{file_id}/comments")
@require_login
def file_comments_redirect(request: Request, file_id: int):
"""
Redirect /files/{file_id}/comments to /files/{file_id}/annotations.
"""
from starlette.responses import RedirectResponse
return RedirectResponse(url=f"/files/{file_id}/annotations", status_code=302)
# ---------------------------------------------------------------------------
# Pipeline ↔ Celery-log stage mapping
# ---------------------------------------------------------------------------
@@ -396,9 +589,7 @@ _STEP_TYPE_TO_STAGES: dict[str, list[str]] = {
"embed_metadata": ["embed_metadata_into_pdf"],
"compute_embedding": ["compute_embedding"],
"send_to_destinations": ["finalize_document_storage", "send_to_all_destinations"],
# "classify" is defined in PIPELINE_STEP_TYPES but has no Celery log stages yet.
# When a classify task is implemented, add its stage key(s) here.
"classify": [],
"classify": ["classify_document"],
}
# These internal bookkeeping stages are always shown in the flow regardless of
+11 -4
View File
@@ -45,6 +45,9 @@ async def google_drive_setup_page(
except (json.JSONDecodeError, TypeError):
cfg = {}
folder_id = cfg.get("folder_id", "")
# Provide system-wide OAuth credentials when available so users can
# authorize without registering their own Google Cloud app.
has_system_credentials = bool(settings.google_drive_client_id and settings.google_drive_client_secret)
return templates.TemplateResponse(
"google_drive.html",
{
@@ -58,10 +61,13 @@ async def google_drive_setup_page(
"use_oauth": True,
"oauth_configured": bool(integration.credentials),
"sa_configured": False,
"client_id": False,
"client_id_value": "",
"client_secret": False,
"client_secret_value": "",
"has_system_credentials": has_system_credentials,
"client_id": bool(settings.google_drive_client_id) if has_system_credentials else False,
"client_id_value": (settings.google_drive_client_id or "" if has_system_credentials else ""),
"client_secret": bool(settings.google_drive_client_secret) if has_system_credentials else False,
"client_secret_value": (
settings.google_drive_client_secret or "" if has_system_credentials else ""
),
"refresh_token": False,
"refresh_token_value": "",
"has_credentials_json": False,
@@ -90,6 +96,7 @@ async def google_drive_setup_page(
"use_oauth": use_oauth,
"oauth_configured": oauth_configured,
"sa_configured": sa_configured,
"has_system_credentials": bool(settings.google_drive_client_id and settings.google_drive_client_secret),
"client_id": bool(settings.google_drive_client_id),
"client_id_value": settings.google_drive_client_id or "",
"client_secret": bool(settings.google_drive_client_secret),
+10 -5
View File
@@ -44,6 +44,9 @@ async def onedrive_setup_page(
cfg = {}
# Support both "folder_path" (WATCH_FOLDER / ONEDRIVE destination)
folder_path = cfg.get("folder_path", cfg.get("folder", ""))
# Provide system-wide app credentials when available so users can
# authorize without registering their own Azure/OneDrive app.
has_system_credentials = bool(settings.onedrive_client_id and settings.onedrive_client_secret)
return templates.TemplateResponse(
"onedrive.html",
{
@@ -54,11 +57,12 @@ async def onedrive_setup_page(
"integration_name": integration.name,
"integration_type": integration.integration_type,
"folder_path": folder_path,
"client_id": False,
"client_id_value": "",
"client_secret": False,
"client_secret_value": "",
"tenant_id": "common",
"has_system_credentials": has_system_credentials,
"client_id": bool(settings.onedrive_client_id) if has_system_credentials else False,
"client_id_value": settings.onedrive_client_id or "" if has_system_credentials else "",
"client_secret": bool(settings.onedrive_client_secret) if has_system_credentials else False,
"client_secret_value": (settings.onedrive_client_secret or "" if has_system_credentials else ""),
"tenant_id": settings.onedrive_tenant_id or "common",
"refresh_token": False,
"refresh_token_value": "",
},
@@ -75,6 +79,7 @@ async def onedrive_setup_page(
"request": request,
"user_mode": False,
"is_configured": is_configured,
"has_system_credentials": bool(settings.onedrive_client_id and settings.onedrive_client_secret),
"client_id": bool(settings.onedrive_client_id),
"client_id_value": settings.onedrive_client_id or "",
"client_secret": bool(settings.onedrive_client_secret),
+340
View File
@@ -195,6 +195,346 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)):
)
@router.get("/admin/connections")
@require_login
@require_admin_access
async def connections_page(request: Request, db: Session = Depends(get_db)):
"""
Connections management page - admin only.
Allows administrators to configure external authentication providers,
SSO settings, and service integrations through a wizard-like interface.
"""
try:
db_settings = get_all_settings_from_db(db)
def _get_effective(key: str):
"""Return DB value if present, else fall back to settings attr."""
if key in db_settings and db_settings[key] is not None:
return db_settings[key]
return getattr(settings, key, None)
def _is_truthy(val) -> bool:
if isinstance(val, bool):
return val
if isinstance(val, str):
return val.lower() in ("true", "1", "yes")
return bool(val)
# Build service status list
services = []
# --- SSO (Authentik / OIDC) ---
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
services.append(
{
"key": "oidc",
"name": _get_effective("oauth_provider_name") or "Single Sign-On",
"icon": "fas fa-lock",
"type": "SSO",
"linked": _oidc_linked,
"description": "OpenID Connect SSO provider",
"settings_keys": [
"authentik_client_id",
"authentik_client_secret",
"authentik_config_url",
"oauth_provider_name",
],
}
)
# --- Google ---
_google_id = _get_effective("social_auth_google_client_id")
_google_secret = _get_effective("social_auth_google_client_secret")
if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not (
_google_id and _google_secret
):
_google_id = _google_id or _get_effective("google_drive_client_id")
_google_secret = _google_secret or _get_effective("google_drive_client_secret")
_google_linked = bool(
_is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret
)
services.append(
{
"key": "google",
"name": "Google",
"icon": "fab fa-google",
"type": "Sign-in authentication",
"linked": _google_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_google_enabled",
"social_auth_google_client_id",
"social_auth_google_client_secret",
"social_auth_google_use_global_credentials",
],
}
)
# --- GitHub ---
_github_linked = bool(
_is_truthy(_get_effective("social_auth_github_enabled"))
and _get_effective("social_auth_github_client_id")
and _get_effective("social_auth_github_client_secret")
)
services.append(
{
"key": "github",
"name": "GitHub",
"icon": "fab fa-github",
"type": "Sign-in authentication",
"linked": _github_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_github_enabled",
"social_auth_github_client_id",
"social_auth_github_client_secret",
],
}
)
# --- Microsoft ---
_ms_id = _get_effective("social_auth_microsoft_client_id")
_ms_secret = _get_effective("social_auth_microsoft_client_secret")
if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret):
_ms_id = _ms_id or _get_effective("onedrive_client_id")
_ms_secret = _ms_secret or _get_effective("onedrive_client_secret")
_microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret)
services.append(
{
"key": "microsoft",
"name": "Microsoft",
"icon": "fab fa-microsoft",
"type": "Sign-in authentication",
"linked": _microsoft_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_microsoft_enabled",
"social_auth_microsoft_client_id",
"social_auth_microsoft_client_secret",
"social_auth_microsoft_tenant",
"social_auth_microsoft_use_global_credentials",
],
}
)
# --- Apple ---
_apple_linked = bool(
_is_truthy(_get_effective("social_auth_apple_enabled"))
and _get_effective("social_auth_apple_client_id")
and _get_effective("social_auth_apple_team_id")
)
services.append(
{
"key": "apple",
"name": "Apple",
"icon": "fab fa-apple",
"type": "Sign-in authentication",
"linked": _apple_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_apple_enabled",
"social_auth_apple_client_id",
"social_auth_apple_team_id",
"social_auth_apple_key_id",
"social_auth_apple_private_key",
],
}
)
# --- Dropbox ---
_dbx_id = _get_effective("social_auth_dropbox_client_id")
_dbx_secret = _get_effective("social_auth_dropbox_client_secret")
if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret):
_dbx_id = _dbx_id or _get_effective("dropbox_app_key")
_dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret")
_dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret)
services.append(
{
"key": "dropbox",
"name": "Dropbox",
"icon": "fab fa-dropbox",
"type": "Sign-in authentication",
"linked": _dropbox_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_dropbox_enabled",
"social_auth_dropbox_client_id",
"social_auth_dropbox_client_secret",
"social_auth_dropbox_use_global_credentials",
],
}
)
# --- Keycloak ---
_keycloak_linked = bool(
_is_truthy(_get_effective("social_auth_keycloak_enabled"))
and _get_effective("social_auth_keycloak_client_id")
and _get_effective("social_auth_keycloak_client_secret")
and _get_effective("social_auth_keycloak_server_url")
and _get_effective("social_auth_keycloak_realm")
)
services.append(
{
"key": "keycloak",
"name": "Keycloak",
"icon": "fas fa-key",
"type": "SSO",
"linked": _keycloak_linked,
"description": "SSO",
"settings_keys": [
"social_auth_keycloak_enabled",
"social_auth_keycloak_client_id",
"social_auth_keycloak_client_secret",
"social_auth_keycloak_server_url",
"social_auth_keycloak_realm",
],
}
)
# --- Generic OAuth2 ---
_generic_oauth2_linked = bool(
_is_truthy(_get_effective("social_auth_generic_oauth2_enabled"))
and _get_effective("social_auth_generic_oauth2_client_id")
and _get_effective("social_auth_generic_oauth2_client_secret")
and _get_effective("social_auth_generic_oauth2_authorize_url")
and _get_effective("social_auth_generic_oauth2_token_url")
)
services.append(
{
"key": "generic_oauth2",
"name": "Generic OAuth2",
"icon": "fas fa-sign-in-alt",
"type": "SSO",
"linked": _generic_oauth2_linked,
"description": "SSO",
"settings_keys": [
"social_auth_generic_oauth2_enabled",
"social_auth_generic_oauth2_client_id",
"social_auth_generic_oauth2_client_secret",
"social_auth_generic_oauth2_authorize_url",
"social_auth_generic_oauth2_token_url",
"social_auth_generic_oauth2_userinfo_url",
"social_auth_generic_oauth2_scope",
"social_auth_generic_oauth2_name",
],
}
)
# --- SAML2 ---
_saml2_configured = bool(
_is_truthy(_get_effective("social_auth_saml2_enabled"))
and _get_effective("social_auth_saml2_sso_url")
and _get_effective("social_auth_saml2_entity_id")
)
services.append(
{
"key": "saml2",
"name": settings.social_auth_saml2_name or "SAML2",
"icon": "fas fa-id-badge",
"type": "SSO (SAML)",
"linked": _saml2_configured,
"description": "SSO (SAML)",
"settings_keys": [
"social_auth_saml2_enabled",
"social_auth_saml2_entity_id",
"social_auth_saml2_sso_url",
"social_auth_saml2_certificate",
"social_auth_saml2_name",
],
}
)
# --- SMTP Mail ---
_smtp_configured = bool(_get_effective("email_host") and _get_effective("email_username"))
services.append(
{
"key": "smtp",
"name": "SMTP Mail",
"icon": "fas fa-envelope",
"type": "Email Notifications",
"linked": _smtp_configured,
"description": "Email Notifications",
"settings_keys": [
"email_host",
"email_port",
"email_username",
"email_password",
"email_use_tls",
"email_sender",
],
}
)
# --- Telegram Bot ---
_telegram_configured = bool(
_is_truthy(_get_effective("telegram_enabled")) and _get_effective("telegram_bot_token")
)
services.append(
{
"key": "telegram",
"name": "Telegram Bot",
"icon": "fab fa-telegram",
"type": "Notifications",
"linked": _telegram_configured,
"description": "Configure Telegram bot connectivity, access controls, and feedback behavior.",
"settings_keys": [
"telegram_enabled",
"telegram_bot_token",
"telegram_chat_id",
],
}
)
# Get setting details for the modal forms
service_settings = {}
for svc in services:
svc_settings = []
for skey in svc["settings_keys"]:
meta = get_setting_metadata(skey)
# Get current effective value
val = _get_effective(skey)
display_val = val
if meta.get("sensitive") and val:
display_val = mask_sensitive_value(val)
svc_settings.append(
{
"key": skey,
"value": val,
"display_value": display_val if display_val is not None else "",
"metadata": meta,
}
)
service_settings[svc["key"]] = svc_settings
# Feature toggles
sso_auto_login = _is_truthy(_get_effective("sso_auto_login"))
qr_login_enabled = _is_truthy(_get_effective("qr_login_enabled"))
frontend_url_configured = bool(_get_effective("public_base_url"))
return templates.TemplateResponse(
"admin_connections.html",
{
"request": request,
"services": services,
"service_settings": service_settings,
"sso_auto_login": sso_auto_login,
"oauth_configured": _oidc_linked,
"qr_login_enabled": qr_login_enabled,
"frontend_url_configured": frontend_url_configured,
"app_version": settings.version,
},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error loading connections page: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load connections page",
)
@router.get("/admin/settings/audit-log")
@require_login
@require_admin_access
+797 -17
View File
@@ -27,9 +27,11 @@ DocuElevate implements rate limiting to protect against abuse and DoS attacks. R
### Default Limits
- **Default endpoints**: 100 requests per minute
- **File upload**: 600 requests per minute
- **File upload**: 600 requests per minute (global) + 20 per user per 60 s (per-user, health-aware)
- **Authentication**: 10 requests per minute
**Per-user upload rate limiting**: Upload endpoints (`/api/ui-upload`, `/api/process-url`) enforce a per-user sliding-window limit that adapts to system load. Under heavy queue depth or high CPU usage, the effective limit is reduced automatically. See the [Configuration Guide](ConfigurationGuide.md#per-user-upload-rate-limiting) for details.
**Note**: Document processing endpoints (OCR, metadata extraction) use built-in queue throttling to control processing rates and prevent upstream API overloads. No additional API-level rate limit is applied to processing endpoints.
### Rate Limit Headers
@@ -53,6 +55,10 @@ RATE_LIMITING_ENABLED=true
RATE_LIMIT_DEFAULT=100/minute
RATE_LIMIT_UPLOAD=600/minute
RATE_LIMIT_AUTH=10/minute
# Per-user upload rate limiting (health-aware)
UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window
UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds
```
See [Configuration Guide](ConfigurationGuide.md) for more details.
@@ -115,7 +121,8 @@ curl -X GET "http://<your-docuelevate-instance>/api/files" \
|--------|----------|-------------|
| `POST` | `/api/api-tokens/` | Create a new token |
| `GET` | `/api/api-tokens/` | List all your tokens |
| `DELETE` | `/api/api-tokens/{id}` | Revoke a token |
| `DELETE` | `/api/api-tokens/{id}` | Revoke (active) or permanently delete (revoked) a token |
| `POST` | `/api/api-tokens/{id}/reactivate` | Reactivate a revoked token |
### Session Authentication
@@ -235,17 +242,33 @@ The DocuElevate browser extension uses this endpoint to send files directly from
**POST** `/api/ui-upload`
Upload one or more files from your computer for processing.
Upload a file from your computer for processing.
**Request**:
- Multipart form data with file(s)
- Multipart form data with a single `file` field
**Response**:
**Response** (new file):
```json
{
"success": true,
"file_ids": [123, 124],
"message": "Files uploaded and queued for processing"
"task_id": "abc-123",
"status": "queued",
"original_filename": "invoice.pdf",
"stored_filename": "a1b2c3d4.pdf"
}
```
**Response** (exact duplicate, when `ENABLE_DEDUPLICATION=True`):
```json
{
"status": "duplicate",
"original_filename": "invoice.pdf",
"stored_filename": "e5f6a7b8.pdf",
"duplicate_of": {
"duplicate_type": "exact",
"original_file_id": 42,
"original_filename": "invoice.pdf",
"message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again."
}
}
```
@@ -1356,7 +1379,7 @@ Test an integration connection without saving. Useful for "Test connection" UI b
{"success": true, "message": "IMAP connection successful"}
```
Supported connection tests: `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported.
Supported connection tests: `DROPBOX`, `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported.
### GET /api/integrations/quota/
@@ -1381,6 +1404,55 @@ Get the current user's integration quota usage.
}
```
## Cloud Provider Folder Browser
Browse folders in connected cloud storage providers. These endpoints are used by the OAuth callback pages to let users select a target folder after authorization.
### POST /api/dropbox/list-folders
List folders in a Dropbox account. Requires a short-lived OAuth access token obtained during the authorization flow.
**Request (form-data):**
| Field | Type | Required | Description |
|----------------|--------|----------|--------------------------------------|
| `access_token` | string | Yes | Dropbox OAuth access token |
| `path` | string | No | Folder path to list (default: root) |
**Response (200):**
```json
{
"folders": [
{ "name": "Documents", "path": "/Documents", "id": "id:abc123" },
{ "name": "Photos", "path": "/Photos", "id": "id:def456" }
],
"path": "/",
"has_more": false
}
```
### POST /api/onedrive/list-folders
List folders in a OneDrive account. Requires a short-lived OAuth access token obtained during the authorization flow.
**Request (form-data):**
| Field | Type | Required | Description |
|----------------|--------|----------|--------------------------------------|
| `access_token` | string | Yes | Microsoft Graph access token |
| `path` | string | No | Folder path to list (default: root) |
**Response (200):**
```json
{
"folders": [
{ "name": "Documents", "path": "/Documents", "id": "abc123", "child_count": 5 },
{ "name": "Pictures", "path": "/Pictures", "id": "def456", "child_count": 12 }
],
"path": "/"
}
```
## Webhooks
Manage webhook configurations for notifying external systems when document events occur. All webhook endpoints require admin access.
@@ -1571,6 +1643,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 +2240,14 @@ Usage tracking records when each token was last used and from which IP address.
### POST /api/api-tokens/
Create a new API token.
Create a new API token. Optionally specify a lifetime in days via
`expires_in_days` (13650). If omitted the token never expires.
**Request:**
```json
{
"name": "CI Pipeline"
"name": "CI Pipeline",
"expires_in_days": 90
}
```
@@ -2147,7 +2262,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 +2285,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 +2307,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:
@@ -2214,6 +2342,316 @@ print(response.json())
```
## Classification Rules
The classification rules API lets you manage custom document classification rules. Rules are evaluated during the `classify` pipeline step to assign a category to each document based on filename patterns, content keywords, and metadata fields.
### Built-in Categories
```bash
GET /api/classification-rules/categories
```
Returns the pre-built classification categories.
**Response (200):**
```json
{
"invoice": "Invoice",
"contract": "Contract",
"receipt": "Receipt",
"letter": "Letter",
"report": "Report",
"bank_statement": "Bank Statement",
"tax_document": "Tax Document",
"insurance": "Insurance Document",
"payslip": "Payslip",
"unknown": "Unknown"
}
```
### Rule Types
```bash
GET /api/classification-rules/rule-types
```
Returns the supported rule types with descriptions.
**Response (200):**
```json
[
{
"type": "filename_pattern",
"label": "Filename Pattern",
"description": "Regex pattern matched against the original filename."
},
{
"type": "content_keyword",
"label": "Content Keyword",
"description": "Pipe-separated keywords matched against the OCR text."
},
{
"type": "metadata_match",
"label": "Metadata Match",
"description": "field=value pattern matched against existing AI metadata."
}
]
```
### List Rules
```bash
GET /api/classification-rules/
```
List all classification rules visible to the current user (system rules + own rules).
**Response (200):**
```json
[
{
"id": 1,
"owner_id": "user@example.com",
"name": "German Invoice Filename",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "(?i)rechnung",
"priority": 10,
"case_sensitive": false,
"enabled": true
}
]
```
### Create Rule
```bash
POST /api/classification-rules/
```
Create a new custom classification rule.
**Request:**
```json
{
"name": "German Invoice Filename",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "(?i)rechnung",
"priority": 10,
"case_sensitive": false,
"enabled": true
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Unique rule name (per user) |
| `category` | string | Yes | Target category (e.g. `invoice`, `contract`, or custom) |
| `rule_type` | string | Yes | One of: `filename_pattern`, `content_keyword`, `metadata_match` |
| `pattern` | string | Yes | Regex (filename), pipe-separated keywords (content), or `field=value` (metadata) |
| `priority` | integer | No | Higher priority rules are evaluated first (default: 0) |
| `case_sensitive` | boolean | No | Case-sensitive matching (default: false) |
| `enabled` | boolean | No | Whether the rule is active (default: true) |
**Response (201):**
```json
{
"id": 1,
"owner_id": "user@example.com",
"name": "German Invoice Filename",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "(?i)rechnung",
"priority": 10,
"case_sensitive": false,
"enabled": true
}
```
### Get Rule
```bash
GET /api/classification-rules/{rule_id}
```
### Update Rule
```bash
PUT /api/classification-rules/{rule_id}
```
**Request (partial update):**
```json
{
"priority": 20,
"enabled": false
}
```
### Delete Rule
```bash
DELETE /api/classification-rules/{rule_id}
```
**Response:** `204 No Content`
## Automation (Zapier / Make.com)
Manage automation hook subscriptions for integrating DocuElevate with external platforms like Zapier and Make.com. All endpoints require API token authentication (`Authorization: Bearer <token>`).
### Supported Events
The automation system shares event types with the [Webhooks](#webhooks) subsystem:
| Event | Description |
|-------|-------------|
| `document.uploaded` | A new document has been ingested |
| `document.processed` | A document finished processing successfully |
| `document.failed` | Document processing failed |
| `user.signup` | A new user account was created |
| `user.plan_changed` | A user's subscription plan changed |
| `user.payment_issue` | A payment issue was reported for a user |
### GET /api/automation/events
List all valid event types that automation hooks can subscribe to.
**Response (200):**
```json
["document.failed", "document.processed", "document.uploaded", "user.payment_issue", "user.plan_changed", "user.signup"]
```
### POST /api/automation/hooks/subscribe
Subscribe to DocuElevate events. Zapier and Make.com call this endpoint to register a webhook URL that receives event notifications.
**Request:**
```bash
curl -X POST "http://your-instance/api/automation/hooks/subscribe" \
-H "Authorization: Bearer de_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
"events": ["document.processed", "document.uploaded"],
"hook_type": "zapier",
"secret": "optional-signing-secret",
"description": "My Zap for processed documents"
}'
```
**Response (201):**
```json
{
"id": 1,
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
"events": ["document.processed", "document.uploaded"],
"is_active": true,
"hook_type": "zapier",
"description": "My Zap for processed documents",
"has_secret": true
}
```
### GET /api/automation/hooks
List all automation hook subscriptions.
**Response (200):**
```json
[
{
"id": 1,
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
"events": ["document.processed", "document.uploaded"],
"is_active": true,
"hook_type": "zapier",
"description": "My Zap for processed documents",
"has_secret": true
}
]
```
### DELETE /api/automation/hooks/{hook_id}
Unsubscribe an automation hook. Zapier calls this when a Zap is turned off or deleted.
**Response (204):** No content.
### GET /api/automation/triggers/sample/{event}
Get sample trigger data for Zapier field mapping. Zapier uses this during Zap setup to discover available fields.
**Request:**
```bash
curl "http://your-instance/api/automation/triggers/sample/document.processed" \
-H "Authorization: Bearer de_your_token_here"
```
**Response (200):**
```json
[
{
"id": "evt_sample0002",
"event": "document.processed",
"timestamp": 1710000060.0,
"document_id": 42,
"filename": "invoice_2024.pdf",
"status": "processed",
"title": "Invoice #1234",
"owner_id": "user@example.com"
}
]
```
### POST /api/automation/actions/upload
Upload a document from an automation platform. This incoming action endpoint allows Zapier or Make.com to push documents into DocuElevate for processing.
**Request:**
```bash
curl -X POST "http://your-instance/api/automation/actions/upload" \
-H "Authorization: Bearer de_your_token_here" \
-F "file=@/path/to/document.pdf"
```
**Response (200):**
```json
{
"status": "accepted",
"filename": "document.pdf",
"task_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
### Zapier-Compatible Payload Format
When events fire, automation hooks receive a **flat JSON payload** (no nested `data` key) that Zapier and Make.com can easily map:
```json
{
"id": "evt_a1b2c3d4e5f67890",
"event": "document.processed",
"timestamp": 1710000060.0,
"document_id": 42,
"filename": "invoice_2024.pdf",
"status": "processed",
"title": "Invoice #1234",
"owner_id": "user@example.com"
}
```
The `id` field is unique per event and is used by Zapier for deduplication. If a `secret` was provided during subscription, an `X-Webhook-Signature` header with an HMAC-SHA256 signature is included.
### Retry Behavior
Automation hook deliveries follow the same retry policy as regular webhooks: up to 3 retries with exponential backoff (60 s, 300 s, 900 s) and ±20% jitter.
## Further Assistance
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
@@ -2268,9 +2706,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
@@ -2466,3 +2909,340 @@ Move original files to a reimport folder, wipe everything, and configure the rei
}
}
```
---
## Comments & Annotations
Threaded comments and PDF annotations for document collaboration.
### List Comments
**GET** `/api/files/{file_id}/comments`
Returns all comments for a document, organized into a threaded tree.
**Response (200):**
```json
{
"file_id": 1,
"comments": [
{
"id": 1,
"file_id": 1,
"user_id": "alice",
"parent_id": null,
"body": "Please review section 3.",
"mentions": ["bob"],
"is_resolved": false,
"created_at": "2026-03-21T12:00:00+00:00",
"updated_at": "2026-03-21T12:00:00+00:00",
"replies": [
{
"id": 2,
"file_id": 1,
"user_id": "bob",
"parent_id": 1,
"body": "Done!",
"mentions": [],
"is_resolved": false,
"created_at": "2026-03-21T12:05:00+00:00",
"updated_at": "2026-03-21T12:05:00+00:00",
"replies": []
}
]
}
],
"total": 2
}
```
### Create Comment
**POST** `/api/files/{file_id}/comments`
Create a new comment on a document. @mentions are automatically extracted from the body.
**Request:**
```json
{
"body": "Hey @bob, please review this section.",
"parent_id": null
}
```
**Response (201):**
```json
{
"id": 3,
"file_id": 1,
"user_id": "alice",
"parent_id": null,
"body": "Hey @bob, please review this section.",
"mentions": ["bob"],
"is_resolved": false,
"created_at": "2026-03-21T12:10:00+00:00",
"updated_at": "2026-03-21T12:10:00+00:00"
}
```
### Update Comment
**PUT** `/api/files/{file_id}/comments/{comment_id}`
Update the body of an existing comment. Only the comment author may update it.
**Request:**
```json
{
"body": "Updated comment text @charlie"
}
```
### Delete Comment
**DELETE** `/api/files/{file_id}/comments/{comment_id}`
Delete a comment. Only the comment author may delete it.
**Response:** `204 No Content`
### Resolve / Unresolve Comment
**PATCH** `/api/files/{file_id}/comments/{comment_id}/resolve`
Mark a comment thread as resolved or unresolved.
**Request:**
```json
{
"is_resolved": true
}
```
### List Annotations
**GET** `/api/files/{file_id}/annotations`
Returns all PDF page annotations for a document, ordered by page then creation time.
**Response (200):**
```json
{
"file_id": 1,
"annotations": [
{
"id": 1,
"file_id": 1,
"user_id": "alice",
"page": 1,
"x": 100.0,
"y": 200.0,
"width": 150.0,
"height": 20.0,
"content": "Important paragraph",
"annotation_type": "highlight",
"color": "#ffff00",
"created_at": "2026-03-21T12:00:00+00:00",
"updated_at": "2026-03-21T12:00:00+00:00"
}
],
"total": 1
}
```
### Create Annotation
**POST** `/api/files/{file_id}/annotations`
Create a new annotation on a PDF page.
**Request:**
```json
{
"page": 1,
"x": 100.0,
"y": 200.0,
"width": 150.0,
"height": 20.0,
"content": "Important paragraph",
"annotation_type": "highlight",
"color": "#ffff00"
}
```
Allowed `annotation_type` values: `note`, `highlight`, `underline`, `strikethrough`.
### Update Annotation
**PUT** `/api/files/{file_id}/annotations/{annotation_id}`
Update an existing annotation. Only the annotation author may update it.
**Request** (all fields optional):
```json
{
"content": "Updated note",
"color": "#00ff00"
}
```
### Delete Annotation
**DELETE** `/api/files/{file_id}/annotations/{annotation_id}`
Delete an annotation. Only the annotation author may delete it.
**Response:** `204 No Content`
### List Mentionable Users
**GET** `/api/users/mentionable`
Returns all non-blocked user profiles for the @mention autocomplete.
**Response (200):**
```json
[
{ "user_id": "alice", "display_name": "Alice Anderson" },
{ "user_id": "bob", "display_name": "Bob Baker" }
]
```
---
## File Sharing & Permissions
DocuElevate supports per-user document sharing with role-based access control.
### Roles
| Role | View | Comment / Annotate | Edit metadata | Delete | Share |
|----------|------|--------------------|---------------|--------|-------|
| `owner` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `editor` | ✓ | ✓ | ✓ | ✗ | ✗ |
| `viewer` | ✓ | ✓ | ✗ | ✗ | ✗ |
- Only the **file owner** can share a document, change roles, or delete the document.
- When a user is **@mentioned** in a comment they are automatically granted `viewer` access to the document (multi-user mode only).
---
### List Shares
**GET** `/api/files/{file_id}/shares`
Returns all active shares for a document. Only the file owner (or an admin) may call this endpoint.
**Response (200):**
```json
[
{
"id": 1,
"file_id": 42,
"owner_id": "alice",
"shared_with_user_id": "bob",
"role": "viewer",
"created_at": "2026-03-22T10:00:00+00:00",
"updated_at": "2026-03-22T10:00:00+00:00"
}
]
```
**Error Responses:**
- `403`: Not the file owner
- `404`: File not found
---
### Create Share
**POST** `/api/files/{file_id}/shares`
Share a document with another user. Only the file owner may call this endpoint. If the user already has a share, their role is updated.
**Request:**
```json
{
"shared_with_user_id": "bob",
"role": "viewer"
}
```
`role` must be `"viewer"` (default) or `"editor"`.
**Response (201):**
```json
{
"id": 1,
"file_id": 42,
"owner_id": "alice",
"shared_with_user_id": "bob",
"role": "viewer",
"created_at": "2026-03-22T10:00:00+00:00",
"updated_at": "2026-03-22T10:00:00+00:00"
}
```
**Error Responses:**
- `403`: Not the file owner
- `422`: Invalid role, empty user ID, or sharing with self
---
### Update Share Role
**PUT** `/api/files/{file_id}/shares/{share_id}`
Change the role of an existing share. Only the file owner may call this endpoint.
**Request:**
```json
{
"role": "editor"
}
```
**Response (200):** Updated share object.
**Error Responses:**
- `403`: Not the file owner
- `404`: Share not found
- `422`: Invalid role
---
### Revoke Share
**DELETE** `/api/files/{file_id}/shares/{share_id}`
Remove a user's access to a document. Only the file owner may revoke shares.
**Response (200):**
```json
{ "status": "success", "message": "Share revoked successfully" }
```
**Error Responses:**
- `403`: Not the file owner
- `404`: Share not found
---
### List Shared With
**GET** `/api/files/{file_id}/shared-with`
Returns who a document is shared with. Accessible to any user with at least `viewer` access (owner, editors, and viewers can all call this).
**Response (200):**
```json
[
{
"share_id": 1,
"user_id": "bob",
"display_name": "Bob Baker",
"role": "viewer"
}
]
```
+285
View File
@@ -0,0 +1,285 @@
# Apple App Store Compliance Audit Report
This document details the findings from a comprehensive audit of the DocuElevate mobile app against Apple's App Store Review Guidelines, Human Interface Guidelines (HIG), and privacy requirements. It covers all areas of compliance, risks for rejection, and recommendations.
> **Last Audited:** March 2026
> **App Version:** 1.0.0
> **Expo SDK:** 54.0.0
> **Bundle ID:** `org.docuelevate.mobile`
---
## Executive Summary
The DocuElevate mobile app is broadly compliant with Apple's App Store requirements. The following issues were identified and resolved as part of this audit:
| Issue | Severity | Status |
|-------|----------|--------|
| Unused `fetch` background mode declared | High | ✅ Fixed |
| Missing privacy manifest for required reason APIs | High | ✅ Fixed |
| No account deletion option (Guideline 5.1.1(v)) | Critical | ✅ Fixed |
| No Privacy Policy / Terms of Service links in-app | High | ✅ Fixed |
| Emoji used as UI icons instead of platform-native icons | Medium | ✅ Fixed |
| Missing app version display | Low | ✅ Fixed |
| Unused `Switch` import in ProfileScreen | Low | ✅ Fixed |
---
## 1. Human Interface Guidelines (HIG)
### 1.1 Navigation & Tab Bar ✅
- The app uses a standard bottom tab bar with three tabs: Upload, Files, and Profile.
- Tab icons use **Ionicons** (an icon set that closely maps to Apple's SF Symbols).
- Active/inactive tab colors follow iOS conventions (`#1e40af` active, `#9ca3af` inactive).
- Header styling uses a solid color background with white text, consistent with iOS navigation bar patterns.
### 1.2 Icons & Visual Assets ✅
- **App icon:** Custom `icon.png` provided at root level; Expo handles generating all required sizes.
- **Splash screen:** Uses branded splash with `contain` resize mode and matching background color.
- **Adaptive icon (Android):** Properly configured with foreground image and background color.
- **Action buttons:** Previously used emoji characters (📷, 🖼️, 📄) which render inconsistently across iOS versions. **Fixed:** Now using Ionicons (`camera-outline`, `images-outline`, `document-outline`).
- **Status indicators:** Previously used emoji (✅, ❌, ⏳, ⚙️). **Fixed:** Now using Ionicons with semantic colors.
### 1.3 Typography & Colors ✅
- Uses system fonts (default React Native text rendering uses San Francisco on iOS).
- Color palette (`#1e40af` primary blue, semantic reds/greens/grays) provides sufficient contrast ratios.
- Text sizes follow iOS recommended minimums (body text ≥ 13pt).
### 1.4 Touch Targets ✅
- All interactive elements have `minHeight: 44` or `minHeight: 48` (meets Apple's 44×44pt minimum).
- Back links, cancel buttons, and retry buttons all meet minimum touch target requirements.
### 1.5 Safe Areas ✅
- The app uses `react-native-safe-area-context` (`SafeAreaProvider`) to respect device notches, Dynamic Island, and home indicator.
### 1.6 Dark Mode ✅
- `userInterfaceStyle: "automatic"` is set in `app.json`, enabling automatic dark mode support.
---
## 2. Privacy & Data Usage
### 2.1 Permission Descriptions ✅
All iOS permission strings (Info.plist keys) are present and provide clear, specific descriptions of why each permission is needed:
| Permission | Key | Description |
|-----------|-----|-------------|
| Camera | `NSCameraUsageDescription` | "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload." |
| Photo Library (Read) | `NSPhotoLibraryUsageDescription` | "DocuElevate accesses your photo library to select documents for upload." |
| Photo Library (Write) | `NSPhotoLibraryAddUsageDescription` | "DocuElevate saves scanned documents to your photo library." |
**Assessment:** All descriptions clearly explain the purpose, which is a requirement for App Review approval.
### 2.2 Push Notifications ✅
- Push notification permission is requested at runtime (not at launch) when the user enters the authenticated area.
- The app works gracefully without push notifications if permission is denied.
- Device tokens are registered via a dedicated backend endpoint.
### 2.3 Background Modes ✅ (Fixed)
- **Previous state:** `UIBackgroundModes` included `["fetch", "remote-notification"]`.
- **Issue:** The app does not implement background fetch (`application:performFetchWithCompletionHandler:`). Apple may reject apps that declare background modes they don't actively use (Guideline 2.5.4).
- **Fix:** Removed `fetch` from `UIBackgroundModes`. Only `remote-notification` remains, which is required for push notification delivery.
### 2.4 Privacy Manifest ✅ (Fixed)
Starting in Spring 2024, Apple requires a privacy manifest (`PrivacyInfo.xcprivacy`) for apps using specific APIs. The following required reason APIs are used by the app's dependencies:
| API Category | Reason Code | Justification |
|-------------|-------------|---------------|
| `NSPrivacyAccessedAPICategoryUserDefaults` | `CA92.1` | Used by `@react-native-async-storage/async-storage` for user preferences |
| `NSPrivacyAccessedAPICategoryFileTimestamp` | `C617.1` | Used by `expo-file-system` to read file metadata |
| `NSPrivacyAccessedAPICategoryDiskSpace` | `E174.1` | Used by Expo runtime for storage space checks |
| `NSPrivacyAccessedAPICategorySystemBootTime` | `35F9.1` | Used by React Native's timing APIs |
The privacy manifest is configured via `expo-build-properties` plugin in `app.json`, which ensures it is included in the generated Xcode project during EAS Build.
### 2.5 Tracking & Analytics ✅
- `NSPrivacyTracking: false` — the app does **not** track users.
- `NSPrivacyCollectedDataTypes: []` — no data types are collected for tracking.
- No analytics SDKs (Firebase Analytics, Amplitude, Mixpanel, etc.) are included.
- No App Tracking Transparency (ATT) prompt is needed.
### 2.6 Encryption Declaration ✅
- `ITSAppUsesNonExemptEncryption: false` — the app uses only standard HTTPS/TLS for network communication, which is exempt from export compliance requirements.
### 2.7 Data Storage Security ✅
- API tokens are stored in the device keychain via `expo-secure-store` (uses iOS Keychain Services).
- No sensitive data is stored in `AsyncStorage` or `UserDefaults`.
- Server URL is stored in secure storage, not in plain text files.
---
## 3. App Store Review Guidelines Compliance
### 3.1 Functionality (Guideline 2.x) ✅
- **2.1 App Completeness:** The app provides a complete, functional experience. All advertised features (camera capture, file upload, document list, push notifications) work as described.
- **2.3 Accurate Metadata:** App name ("DocuElevate"), description, and screenshots should accurately reflect the app's functionality.
- **2.5.4 Background Modes:** Only `remote-notification` is declared, which is actively used. ✅ Fixed.
### 3.2 Content & Intellectual Property (Guideline 3.x) ✅
- No third-party trademarked content is used.
- The app does not display user-generated content publicly (documents are private to each user).
- No copyrighted content is bundled with the app.
### 3.3 Business (Guideline 3.1.x) ✅
- The app does not include in-app purchases, subscriptions, or payment processing.
- No physical goods or services are sold through the app.
- Authentication is handled via self-hosted or enterprise SSO — no Apple Sign-In requirement applies (Apple Sign-In is required only when third-party social login options like Google/Facebook are offered as the primary login method; enterprise SSO to a self-hosted server is exempt).
### 3.4 Safety & Privacy (Guideline 5.x) ✅
- **5.1.1 Data Collection and Storage:** The app collects only what is necessary for its functionality (server URL, auth token, push token).
- **5.1.1(v) Account Deletion:** ✅ Fixed. Users can now initiate account deletion from the Profile screen, which opens the server's account deletion page in the browser.
- **5.1.2 Data Use and Sharing:** No data is shared with third parties or used for advertising.
### 3.5 Privacy Policy ✅ (Fixed)
- **Requirement:** Apple requires all apps to have an accessible privacy policy.
- **Fix:** Privacy Policy and Terms of Service links are now accessible from the Profile screen, opening the server's hosted policy pages.
- **App Store Connect:** The privacy policy URL must also be provided in App Store Connect during submission.
### 3.6 Login & Authentication ✅
- Two login methods are available: SSO (browser-based OAuth) and QR code scanning.
- Both methods provide clear error messages on failure.
- The app correctly handles authentication cancellation.
- Session restoration on app launch is implemented.
- **Demo Account:** For App Review, a demo account may need to be provided in App Store Connect's review notes. Ensure the review team can access a test server.
---
## 4. Technical Compliance
### 4.1 API Usage ✅
- No private APIs are used (all functionality comes from Expo SDK and React Native public APIs).
- No deprecated APIs are used that would trigger rejection.
### 4.2 Network Security ✅
- The app validates server URLs require `http://` or `https://` scheme.
- All API calls use Bearer token authentication over HTTPS.
- App Transport Security (ATS) is not explicitly disabled — default iOS ATS rules apply.
### 4.3 Deep Linking ✅
- Custom URL scheme `docuelevate://` is properly registered.
- Deep link handling for QR login (`docuelevate://qr-login`) and file sharing is implemented correctly.
- `WebBrowser.openAuthSessionAsync` is used for OAuth, which properly handles the authentication session lifecycle.
### 4.4 Document Handling ✅
- `CFBundleDocumentTypes` properly declares supported file types.
- `LSSupportsOpeningDocumentsInPlace: false` ensures iOS copies shared files to the app's accessible Inbox directory, avoiding security-scoped URL issues.
- The `+not-found.tsx` handler correctly intercepts iOS "Open In…" file paths.
- `UploadScreen` uses `expo-file-system` to copy external files to cache before uploading for reliable file access.
### 4.5 Crash Resistance ✅
- All network calls are wrapped in try/catch blocks.
- Error states are displayed to users with actionable recovery options (retry buttons).
- Permission denials are handled gracefully with explanatory messages.
---
## 5. Onboarding & First-Run Experience
### 5.1 Welcome Screen ✅
- Clean, informative welcome screen with app branding and feature highlights.
- Clear "Get Started" call-to-action leading to the login screen.
- No misleading claims or functionality promises.
### 5.2 Login Flow ✅
- Server URL entry with input validation.
- Two clear authentication options (SSO and QR code).
- Error handling with user-friendly alert dialogs.
- Back navigation available from all auth screens.
### 5.3 First-Run Permissions ✅
- Camera permission is requested at the point of use (when tapping Camera button), not at launch.
- Photo library permission is requested at the point of use.
- Push notification permission is requested after authentication, not before.
- All permission requests include clear usage descriptions.
---
## 6. Remaining Recommendations
### 6.1 App Store Connect Preparation
Before submission, ensure the following are configured in App Store Connect:
- [ ] **Privacy Policy URL** — must point to the server's `/privacy` endpoint
- [ ] **App Store description** — accurate description of features
- [ ] **Screenshots** — for iPhone and iPad (since `supportsTablet: true`)
- [ ] **App category** — "Business" or "Productivity"
- [ ] **Age rating** — complete the questionnaire (likely 4+)
- [ ] **Review notes** — provide demo server URL and test credentials for the Apple review team
- [ ] **Privacy Nutrition Labels** — declare data types collected (device ID for push notifications, authentication tokens)
### 6.2 Accessibility Enhancements (Recommended)
While the app includes `accessibilityRole` and `accessibilityLabel` on interactive elements, consider:
- Adding `accessibilityHint` to buttons where the action isn't immediately obvious.
- Testing with VoiceOver to ensure all screens are fully navigable.
- Ensuring all status changes are announced to screen readers.
### 6.3 iPad Support
The app declares `supportsTablet: true`. Ensure:
- UI scales appropriately on iPad screen sizes.
- Split View and Slide Over multitasking work correctly.
- Touch targets remain accessible on larger screens.
### 6.4 Localization (Future Enhancement)
- The app currently uses English-only strings.
- For broader App Store reach, consider localizing the app name, description, and in-app strings.
---
## 7. Compliance Checklist Summary
| Area | Status | Notes |
|------|--------|-------|
| Human Interface Guidelines | ✅ Pass | Ionicons used for platform-consistent iconography |
| App Icons & Visual Assets | ✅ Pass | All required assets provided |
| Device Data Usage | ✅ Pass | Camera, photos, notifications properly handled |
| Privacy Disclosures | ✅ Pass | Info.plist keys and privacy manifest configured |
| Background Modes | ✅ Pass | Only `remote-notification` declared |
| Restricted APIs | ✅ Pass | No private or deprecated APIs used |
| Content Standards | ✅ Pass | No misleading or inappropriate content |
| Functionality | ✅ Pass | Complete, functional app experience |
| Business Model | ✅ Pass | No IAP conflicts |
| Safety & Privacy | ✅ Pass | Account deletion available, privacy policy linked |
| Onboarding | ✅ Pass | Clear, permission-respectful first-run experience |
| Privacy Manifest | ✅ Pass | Required reason APIs declared |
---
## References
- [Apple App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/)
- [Apple Privacy Manifest Requirements](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files)
- [App Store Connect Help](https://developer.apple.com/help/app-store-connect/)
+7 -1
View File
@@ -15,6 +15,7 @@ This guide explains how to configure authentication for DocuElevate to secure yo
| `AUTHENTIK_CLIENT_SECRET` | Client secret for OpenID Connect authentication |
| `AUTHENTIK_CONFIG_URL` | OpenID Connect discovery URL |
| `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button |
| `SSO_AUTO_LOGIN` | Auto-redirect to SSO login (skips the login page) |
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
@@ -24,7 +25,12 @@ DocuElevate supports multiple authentication methods that can be used independen
1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate
2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0
3. **Social Login** - Sign in with Google, Microsoft, Apple, or Dropbox accounts (see [Social Login Setup Guide](SocialLoginSetup.md))
3. **Social Login** - Sign in with Google, Microsoft, Apple, Dropbox, or GitHub accounts (see [Social Login Setup Guide](SocialLoginSetup.md))
4. **Keycloak SSO** - Self-hosted identity management via Keycloak
5. **Generic OAuth2** - Any OAuth2-compatible identity provider
6. **SAML2** - Enterprise SSO via SAML 2.0
> **Admin Connections Page:** You can configure all authentication providers through the admin **Connections** page at `/admin/connections`.
## Session Security
+117 -7
View File
@@ -11,10 +11,15 @@ Configuration is primarily done through environment variables specified in a `.e
| **Variable** | **Description** | **Example** |
|------------------------|----------------------------------------------------------|--------------------------------|
| `DATABASE_URL` | Path/URL to the SQLite database (or other SQL backend). Use the [Database Wizard](/database-wizard) for guided setup. See [Database Configuration](DatabaseConfiguration.md). | `sqlite:///./app/database.db` |
| `DB_POOL_SIZE` | Number of persistent connections in the pool per worker (PostgreSQL/MySQL only; ignored for SQLite). | `10` |
| `DB_MAX_OVERFLOW` | Additional connections beyond `DB_POOL_SIZE` under burst load (PostgreSQL/MySQL only). | `20` |
| `DB_POOL_TIMEOUT` | Seconds to wait for a pool connection before raising `TimeoutError` (PostgreSQL/MySQL only). | `30` |
| `DB_POOL_RECYCLE` | Recycle connections after this many seconds to avoid stale connections (PostgreSQL/MySQL only). | `1800` |
| `REDIS_URL` | URL for Redis, used by Celery for broker & result store. | `redis://redis:6379/0` |
| `WORKDIR` | Working directory for the application. | `/workdir` |
| `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` |
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
| `PUBLIC_BASE_URL` | Full public base URL including scheme (e.g., `https://docuelevate.example.com`). When set, overrides auto-detected URLs used for OAuth redirect URIs. **Required when your reverse proxy does not forward `X-Forwarded-Proto` headers.** | *(not set)* |
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
| `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` |
| `FACTORY_RESET_ON_STARTUP` | Wipe all user data on every startup (demo/testing). | `false` |
@@ -81,6 +86,28 @@ Control how the web UI queues and paces file uploads to avoid overwhelming the b
**Example**: With `UPLOAD_CONCURRENCY=3` and `UPLOAD_QUEUE_DELAY_MS=500`, a directory of 5,000 files is uploaded ≈ 3 at a time with 500 ms pacing the backend processes files at its own rate while the queue drains in the background without triggering API rate limits.
### Per-User Upload Rate Limiting
Server-side rate limiting that prevents any single user from overwhelming the system with bulk uploads. The limiter uses a Redis-backed sliding window and dynamically adjusts limits based on system health.
| **Variable** | **Description** | **Default** |
|--------------------------------|------------------------------------------------------------------------------------------------------------------------------|-------------|
| `UPLOAD_RATE_LIMIT_PER_USER` | Maximum uploads allowed per user within the sliding window. Effective limit may be reduced under load. | `20` |
| `UPLOAD_RATE_LIMIT_WINDOW` | Sliding window size in seconds. | `60` |
**Health-aware dynamic limiting**: The effective per-user limit is automatically reduced when the system is under heavy load:
| **System condition** | **Effective limit** | **Trigger** |
|--------------------------------|---------------------|--------------------------------|
| Normal | 100 % of base | Queue < 50, CPU load normal |
| Moderate load | 50 % of base | Queue 50100 or CPU > 1.5× |
| High load | 25 % of base | Queue 100200 or CPU > 2× |
| Critical load | 10 % of base | Queue > 200 or CPU > 3× |
When a user exceeds the limit, the server returns **HTTP 429 Too Many Requests** with a `Retry-After` header. The browser client (see *Client-Side Upload Throttling* above) automatically pauses and retries.
> **Note**: The limiter fails open — if Redis is unavailable, all uploads are allowed through so that a monitoring outage never blocks document processing.
### File Upload Size Limits
**Security Feature**: Control file upload sizes to prevent resource exhaustion attacks. See [SECURITY_AUDIT.md](../SECURITY_AUDIT.md#5-file-upload-size-limits) for security details.
@@ -379,7 +406,7 @@ Credentials are encrypted at rest using Fernet encryption.
### Social Login Providers
Social login lets users sign in with their existing Google, Microsoft, Apple, or Dropbox accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md).
Social login lets users sign in with their existing Google, Microsoft, Apple, Dropbox, or GitHub accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md).
| **Variable** | **Description** | **Default** |
|---|---|---|
@@ -398,6 +425,33 @@ Social login lets users sign in with their existing Google, Microsoft, Apple, or
| `SOCIAL_AUTH_DROPBOX_ENABLED` | Enable Dropbox Sign-In. | `false` |
| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | Dropbox OAuth2 App Key. | *(empty)* |
| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | Dropbox OAuth2 App Secret. | *(empty)* |
| `SOCIAL_AUTH_GITHUB_ENABLED` | Enable GitHub Sign-In. | `false` |
| `SOCIAL_AUTH_GITHUB_CLIENT_ID` | GitHub OAuth2 client ID from GitHub Developer Settings. | *(empty)* |
| `SOCIAL_AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth2 client secret. | *(empty)* |
| `SSO_AUTO_LOGIN` | Automatically redirect to SSO login when authentication is required. | `false` |
### SSO Providers
| **Variable** | **Description** | **Default** |
|---|---|---|
| `SOCIAL_AUTH_KEYCLOAK_ENABLED` | Enable Keycloak SSO. | `false` |
| `SOCIAL_AUTH_KEYCLOAK_CLIENT_ID` | Keycloak OAuth2 client ID. | *(empty)* |
| `SOCIAL_AUTH_KEYCLOAK_CLIENT_SECRET` | Keycloak OAuth2 client secret. | *(empty)* |
| `SOCIAL_AUTH_KEYCLOAK_SERVER_URL` | Keycloak server base URL (e.g. `https://keycloak.example.com`). | *(empty)* |
| `SOCIAL_AUTH_KEYCLOAK_REALM` | Keycloak realm name. | *(empty)* |
| `SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED` | Enable a generic OAuth2 SSO provider. | `false` |
| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_ID` | Generic OAuth2 client ID. | *(empty)* |
| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_SECRET` | Generic OAuth2 client secret. | *(empty)* |
| `SOCIAL_AUTH_GENERIC_OAUTH2_AUTHORIZE_URL` | Generic OAuth2 authorization URL. | *(empty)* |
| `SOCIAL_AUTH_GENERIC_OAUTH2_TOKEN_URL` | Generic OAuth2 token endpoint URL. | *(empty)* |
| `SOCIAL_AUTH_GENERIC_OAUTH2_USERINFO_URL` | Generic OAuth2 userinfo endpoint URL. | *(empty)* |
| `SOCIAL_AUTH_GENERIC_OAUTH2_SCOPE` | Space-separated list of OAuth2 scopes. | `openid profile email` |
| `SOCIAL_AUTH_GENERIC_OAUTH2_NAME` | Display name for the provider button. | `OAuth2` |
| `SOCIAL_AUTH_SAML2_ENABLED` | Enable SAML2 SSO authentication. | `false` |
| `SOCIAL_AUTH_SAML2_ENTITY_ID` | SAML2 Identity Provider Entity ID. | *(empty)* |
| `SOCIAL_AUTH_SAML2_SSO_URL` | SAML2 Identity Provider SSO URL. | *(empty)* |
| `SOCIAL_AUTH_SAML2_CERTIFICATE` | SAML2 Identity Provider X.509 certificate (PEM format). | *(empty)* |
| `SOCIAL_AUTH_SAML2_NAME` | Display name for the SAML2 provider. | `SAML2` |
### Multi-User Mode
@@ -738,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
```
**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes.
**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed.
#### X-Frame-Options
@@ -1348,6 +1402,9 @@ For detailed setup instructions, see the [Amazon S3 Setup Guide](AmazonS3Setup.m
| `NOTIFY_ON_USER_SIGNUP` | Send admin notification when a new user signs up (`True`/`False`, default `True`) |
| `NOTIFY_ON_PLAN_CHANGE` | Send admin notification when a user changes their subscription plan (`True`/`False`, default `True`) |
| `NOTIFY_ON_PAYMENT_ISSUE` | Send admin notification when a payment issue is reported for a user (`True`/`False`, default `True`) |
| `TELEGRAM_ENABLED` | Enable Telegram bot notifications. | `false` |
| `TELEGRAM_BOT_TOKEN` | Telegram Bot API token from @BotFather. | *(empty)* |
| `TELEGRAM_CHAT_ID` | Telegram chat ID to send notifications to. | *(empty)* |
#### User-Event Notifications
@@ -1427,6 +1484,26 @@ Configurations are stored in the database and managed through the API (see [API
Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure.
### Automation Hooks (Zapier / Make.com)
Automation hooks enable integration with external automation platforms such as
[Zapier](https://zapier.com) and [Make.com](https://make.com) (formerly Integromat).
| **Variable** | **Description** | **Default** |
|----------------------------|------------------------------------------------------------------------------------------------|-------------|
| `AUTOMATION_HOOKS_ENABLED` | Enable or disable Zapier / Make.com automation hook subscriptions and delivery (`True`/`False`) | `True` |
When enabled, external platforms can:
- **Subscribe** to DocuElevate events via `POST /api/automation/hooks/subscribe` (outgoing triggers)
- **Send documents** to DocuElevate via `POST /api/automation/actions/upload` (incoming actions)
- **Discover fields** via `GET /api/automation/triggers/sample/{event}` (Zapier field mapping)
Automation hooks share the same event types as webhooks (`document.uploaded`, `document.processed`,
`document.failed`, `user.signup`, `user.plan_changed`, `user.payment_issue`) and use a flat
Zapier-compatible JSON payload format. See the [API docs](API.md#automation-zapier--makecom) for
endpoint details and payload examples.
### Backup & Restore
DocuElevate automatically backs up the database on a scheduled basis.
@@ -1528,6 +1605,8 @@ No additional configuration is required — the auto-fill uses the authenticated
DocuElevate integrates with [Sentry](https://sentry.io) for real-time error tracking and performance monitoring. See [SentrySetup.md](./SentrySetup.md) for a full setup guide.
### Server-side (Python SDK)
| Variable | Description | Default |
|---|---|---|
| `SENTRY_DSN` | Sentry DSN URL. When set, error reporting and performance tracing are enabled automatically. Leave blank to disable. | *(unset)* |
@@ -1536,18 +1615,33 @@ DocuElevate integrates with [Sentry](https://sentry.io) for real-time error trac
| `SENTRY_PROFILES_SAMPLE_RATE` | Fraction of profiled transactions sent to Sentry (0.0 1.0). Only active when traces > 0. | `0.0` |
| `SENTRY_SEND_DEFAULT_PII` | Attach PII (IP addresses, user agents) to Sentry events. Disabled by default for GDPR/CCPA compliance. | `false` |
### Browser SDK (JavaScript)
The Sentry Browser SDK is loaded automatically on every rendered page when `SENTRY_DSN` is set. The same DSN is used for both server and browser — the DSN is a *public* key in Sentry's security model and is intentionally embedded in client-side code.
| Variable | Description | Default |
|---|---|---|
| `SENTRY_JS_TRACES_SAMPLE_RATE` | Fraction of browser page-loads captured for client-side performance tracing (0.0 1.0). | `0.0` |
| `SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE` | Fraction of sessions recorded by [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) (0.0 1.0). | `0.0` |
| `SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE` | Fraction of error sessions captured with session replay context (0.0 1.0). | `0.1` |
```bash
# Minimal example
# Minimal example (server + browser)
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
SENTRY_ENVIRONMENT=production
# Optional tuning
# Optional server-side tuning
SENTRY_TRACES_SAMPLE_RATE=0.1
SENTRY_PROFILES_SAMPLE_RATE=0.0
SENTRY_SEND_DEFAULT_PII=false
# Optional browser-side tuning
SENTRY_JS_TRACES_SAMPLE_RATE=0.1
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1
```
> **Note:** Sentry is completely opt-in — if `SENTRY_DSN` is not set, the SDK is never initialised and no data leaves your infrastructure.
> **Note:** Sentry is completely opt-in — if `SENTRY_DSN` is not set, neither SDK is initialised and no data leaves your infrastructure.
## Duplicate Document Detection
@@ -1555,14 +1649,30 @@ DocuElevate detects and flags documents that share the same content, even if the
### Exact Duplicate Detection (SHA-256)
When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the new document is stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=<original_id>`) and no further processing is performed.
When `ENABLE_DEDUPLICATION=True` (the default), each new document is hashed with SHA-256 before processing begins. If the hash matches an existing file record the upload is rejected immediately — no processing task is created, and the temporary file is removed from disk. The `/api/ui-upload` response returns `"status": "duplicate"` together with a `duplicate_of` object that identifies the original file.
If the same file somehow reaches the Celery worker (e.g. via a watch-folder ingest) it is still caught there and stored as a duplicate (`is_duplicate=True`, `duplicate_of_id=<original_id>`) with no further processing.
| Variable | Description | Default |
|---|---|---|
| `ENABLE_DEDUPLICATION` | Hash-based exact duplicate detection on ingest. | `True` |
| `SHOW_DEDUPLICATION_STEP` | Show the "Check for Duplicates" step in the processing timeline UI. | `True` |
An immediate duplicate warning is also included in the `/api/ui-upload` JSON response so the frontend can alert the user before the pipeline completes.
When the upload is an exact duplicate the `/api/ui-upload` response looks like:
```json
{
"status": "duplicate",
"original_filename": "invoice.pdf",
"stored_filename": "abc-123.pdf",
"duplicate_of": {
"duplicate_type": "exact",
"original_file_id": 42,
"original_filename": "invoice.pdf",
"message": "This file is an exact duplicate of an already-processed document. It has not been queued for processing again."
}
}
```
### Near-Duplicate Detection (Content Similarity)
+25 -8
View File
@@ -337,18 +337,26 @@ The Helm chart includes a pre-install and pre-upgrade Job hook that runs `alembi
## Connection Pooling
SQLAlchemy manages a connection pool automatically. The defaults are suitable for most deployments. For high-concurrency or Kubernetes deployments you may want to tune:
SQLAlchemy manages a connection pool automatically. DocuElevate selects the pool
strategy based on the database backend:
- **SQLite** — uses `NullPool` (a fresh connection per request, closed immediately).
This avoids the `QueuePool limit reached` `TimeoutError` that can occur under
concurrent load because SQLite does not benefit from persistent connection pooling.
- **PostgreSQL / MySQL** — uses a bounded `QueuePool` whose size is configurable
via environment variables.
```bash
# Optional — these are set via environment variables if you extend app/database.py
# Typical production values:
DB_POOL_SIZE=10 # Number of persistent connections per worker
DB_MAX_OVERFLOW=20 # Additional connections allowed beyond pool_size
DB_POOL_TIMEOUT=30 # Seconds to wait for a connection from the pool
DB_POOL_RECYCLE=1800 # Recycle connections after 30 minutes (avoids stale connections)
# Tune these for PostgreSQL / MySQL (ignored when using SQLite):
DB_POOL_SIZE=10 # Number of persistent connections per worker (default: 10)
DB_MAX_OVERFLOW=20 # Additional connections allowed beyond pool_size (default: 20)
DB_POOL_TIMEOUT=30 # Seconds to wait for a connection from the pool (default: 30)
DB_POOL_RECYCLE=1800 # Recycle connections after 30 minutes (default: 1800)
```
> **Note:** These environment variables are not exposed in the default `app/config.py`. If you need to tune them, extend the database engine creation in `app/database.py`.
All backends also enable `pool_pre_ping`, which sends a lightweight health-check
before each connection is handed out. This detects stale or dropped connections
and transparently reconnects.
For **PgBouncer** (external connection pooling), point `DATABASE_URL` at your PgBouncer instance and use transaction-mode pooling:
@@ -512,4 +520,13 @@ Then retry `alembic upgrade head`.
Either increase `max_connections` in `postgresql.conf` or add PgBouncer in front of PostgreSQL. The default PostgreSQL `max_connections` is `100`; reduce `DB_POOL_SIZE` per worker to stay within this limit.
### "QueuePool limit reached" TimeoutError (SQLite)
If you see `TimeoutError: QueuePool limit of size 5 overflow 10 reached`, your
deployment is still running an older version of DocuElevate that used a bounded
connection pool for SQLite. Upgrade to the latest release — SQLite now uses
`NullPool`, which eliminates this error entirely. If you are already on the
latest version and are still seeing pool exhaustion, ensure you are not
overriding the engine creation manually.
For more help, see the [Troubleshooting Guide](Troubleshooting.md).
+10 -6
View File
@@ -349,16 +349,18 @@ workdir:
## Scaling
DocuElevate is designed for horizontal scaling. Both API and worker pods are stateless and can be scaled independently.
### Docker Compose
Add more worker containers:
Scale workers (task processing) and API pods (request handling) independently:
```yaml
worker:
deploy:
replicas: 3
```bash
docker compose up -d --scale worker=3 --scale api=2
```
> **Note:** The `beat` service (Celery Beat scheduler) must always run as exactly **one** instance. Do not scale it. It publishes periodic tasks to the Redis broker; workers pick them up.
### Kubernetes / Helm
Enable HPA:
@@ -377,13 +379,15 @@ worker:
maxReplicas: 10
```
The Helm chart deploys a separate **beat** pod (always 1 replica, `Recreate` strategy) so that scheduled tasks are never duplicated when workers scale.
---
## Monitoring
- **Docker Compose**: `docker-compose logs -f`, `docker stats`
- **Kubernetes**: `kubectl logs -l app.kubernetes.io/component=api -f`
- **Prometheus / Grafana**: Scrape the `/api/health` endpoint for readiness; add custom metrics as needed.
- **Prometheus / Grafana**: Scrape the `/api/diagnostic/healthz/ready` endpoint for readiness; add custom metrics as needed.
- **Uptime Kuma**: Set `UPTIME_KUMA_URL` to your push URL for heartbeat monitoring.
---
+28 -3
View File
@@ -28,9 +28,10 @@ End users authorize their own Dropbox integration from the **Integrations** dash
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
2. Create a Dropbox destination integration (or a Watch Folder with `source_type = dropbox`).
3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration.
4. Enter your Dropbox App Key and App Secret in the wizard (or use the global admin credentials if pre-configured).
4. If the administrator has configured system-wide Dropbox app credentials (`DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET`), the wizard defaults to using them — no need to register your own Dropbox app. Uncheck the toggle to use custom credentials if needed.
5. Click **Start Authentication Flow**, authorize access in Dropbox, and the refresh token is automatically saved to your personal integration record.
6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
6. After authorization, an interactive **folder browser** lets you select the target folder directly from your Dropbox — no need to manually type folder paths.
7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
> **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple Dropbox integrations with independent tokens.
@@ -128,7 +129,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).
+1 -1
View File
@@ -30,7 +30,7 @@ End users can authorize their own Google Drive integration directly from the **I
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
2. Create a Google Drive destination integration (or a Watch Folder with `source_type = google_drive`).
3. Click the **Authorize** button — it opens the OAuth wizard pre-loaded with your integration's configuration.
4. Enter your Google OAuth Client ID and Client Secret in the wizard.
4. If the administrator has configured system-wide Google Drive app credentials (`GOOGLE_DRIVE_CLIENT_ID` / `GOOGLE_DRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Google Cloud app. Uncheck the toggle to use custom credentials if needed.
5. Click **Start Authentication Flow** and authorize access in Google.
6. Credentials are saved automatically to your personal integration record; the page redirects back to `/integrations`.
7. Re-authorization is available at any time via the **Re-Authorize** button.
+13 -5
View File
@@ -373,6 +373,8 @@ worker:
replicaCount: 4
```
> **Beat scheduler:** The Helm chart deploys a dedicated `beat` pod (always exactly 1 replica with `Recreate` strategy) that publishes periodic tasks to the Redis broker. Workers consume these tasks — scaling workers does **not** duplicate scheduled jobs.
### Horizontal Pod Autoscaler
```yaml
@@ -433,24 +435,30 @@ externalRedis:
### Kubernetes Probes
The Helm chart configures liveness and readiness probes on the API pods via `/api/health`. Default settings:
The Helm chart configures **unauthenticated** liveness and readiness probes on the API pods so kubelet can reach them without credentials. Default settings:
```yaml
api:
livenessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/live
port: 8000
initialDelaySeconds: 30
periodSeconds: 30
periodSeconds: 20
readinessProbe:
httpGet:
path: /api/health
path: /api/diagnostic/healthz/ready
port: 8000
initialDelaySeconds: 10
initialDelaySeconds: 15
periodSeconds: 10
```
| Endpoint | Auth | Purpose |
|----------|------|---------|
| `/api/diagnostic/healthz/live` | None | Lightweight liveness check — returns 200 if the process is running |
| `/api/diagnostic/healthz/ready` | None | Readiness check — verifies database and Redis connectivity (503 when DB is down) |
| `/api/diagnostic/health` | Required | Full health status for monitoring dashboards (Grafana, Uptime Kuma) |
### Prometheus Scraping
Add annotations to expose metrics (if using a Prometheus-compatible exporter):
+127 -8
View File
@@ -12,9 +12,14 @@ DocuElevate includes a native mobile application for iOS and Android built with
| Auto-generated API token | ✅ | ✅ |
| Camera capture → upload | ✅ | ✅ |
| File picker upload | ✅ | ✅ |
| Multi-image selection from library | ✅ | ✅ |
| Share Sheet / Share Intent | ✅ | ✅ |
| Push notifications | ✅ | ✅ |
| Document list | ✅ | ✅ |
| Document list with search | ✅ | ✅ |
| File detail view with processing logs | ✅ | ✅ |
| Pre-login legal pages (GDPR) | ✅ | ✅ |
| Localization (EN, DE, ES, FR, IT) | ✅ | ✅ |
| Language selection | ✅ | ✅ |
| Dark mode | ✅ | ✅ |
## Getting Started (Development)
@@ -171,8 +176,8 @@ curl -X DELETE -H "Authorization: Bearer <token>" https://your-server/api/mobile
1. Open the **Upload** tab.
2. Tap **Photos**.
3. Select an existing photo from the device's photo library.
4. The image is uploaded and queued for processing.
3. Select one or more photos from the device's photo library (multi-selection is supported).
4. All selected images are uploaded and queued for processing.
### File Picker
@@ -198,6 +203,32 @@ The app registers itself as a share target so any file can be sent directly to D
The URL may arrive as a standard `file://` path **or** under the app's custom `docuelevate://` scheme (e.g. `docuelevate://private/var/mobile/Library/…/file.pdf`). The root layout detects the custom-scheme form and rewrites it to a `file://` URL before forwarding it to the Upload screen through `ShareContext`.
##### Handling "unmatched route" errors from "Open In…"
iOS sometimes delivers the file path under the `docuelevate://` scheme, e.g.:
```
docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf
```
expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. Because no such route exists, it previously threw an **"unmatched route docuelevate://"** error and the upload never completed.
The fix is a catch-all `+not-found.tsx` route (see `mobile/app/+not-found.tsx`). When expo-router cannot match the path, it renders this screen instead. The screen detects that the path is a filesystem path rather than a real in-app route, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext.addPendingFile` deduplicates by URI so the file is only uploaded once.
##### File accessibility and local caching
Shared files may reference paths outside the app's sandbox or use security-scoped URLs that React Native's `fetch` cannot read directly. To guarantee reliable uploads:
- **`LSSupportsOpeningDocumentsInPlace`** is set to `false` in `app.json`, which tells iOS to copy shared files into the app's `Documents/Inbox` directory before handing them to the app.
- **`UploadScreen`** uses `expo-file-system` (`FileSystem.copyAsync`) to copy any `file://` URI that is outside the app's cache/documents directory to a local cache path before uploading. This ensures the file is readable regardless of its origin.
- **MIME type inference**: Both `+not-found.tsx` and the `Linking` handler in `_layout.tsx` infer the MIME type from the file extension (e.g. `.pdf``application/pdf`) so the server receives a correct `Content-Type` instead of `application/octet-stream`.
##### iOS Action / Share Extension (future enhancement)
Apps like DeepL ("Translate in DeepL") and Microsoft Word ("Convert to Word") appear as **Action Extensions** in the iOS share sheet — a system-level feature that requires a separate Xcode target built with Swift or Objective-C. A proper Action Extension runs in its own process and must share authentication credentials with the main app via an iOS **App Group** (shared keychain / shared container).
This level of iOS-native integration is a planned future enhancement. Until it is available, the recommended workflow is the current one: tap **Share → DocuElevate** (the app appears in the "Open With" row of the share sheet via `CFBundleDocumentTypes`).
#### Android implementation
`app.json` declares `ACTION_SEND` and `ACTION_SEND_MULTIPLE` intent filters for `mimeType: "*/*"` in the `android.intentFilters` section. Incoming content URIs are received the same way as on iOS.
@@ -215,6 +246,75 @@ If a file upload fails (e.g. due to network issues or a server error), the faile
The retry re-uses the original file URI so no re-selection is needed.
## Document Search
The **Files** tab includes a search bar at the top that lets users search through their processed documents by filename. Searches are debounced (400ms) to avoid excessive API calls. Clear the search with the ✕ button to return to the full list.
## File Detail View
Tapping any document in the **Files** tab opens a detail view showing:
- **File metadata**: filename, file size, MIME type, upload date, and file hash
- **Processing status**: current status with a colour-coded icon
- **Processing log**: chronological list of processing steps with individual status indicators and timestamps
Pull-to-refresh updates the detail view. This replicates the web interface at `/files/{id}` and `/files/{id}/detail` in a mobile-friendly layout.
## Legal & Compliance
### GDPR & Apple App Store Compliance
Privacy Policy, Terms of Service, and Imprint links are accessible **before login** from both the **Welcome Screen** and the **Login Screen**. This ensures compliance with:
- **GDPR** (General Data Protection Regulation) users must be able to review the privacy policy before providing personal data
- **Apple App Store Review Guidelines** apps must provide accessible privacy information before account creation
Post-login, the same links are available in the **Profile** tab under the "Legal" section.
## Localization (i18n)
The mobile app supports five languages with automatic device-locale detection:
| Language | Code | Status |
|----------|------|--------|
| English | `en` | ✅ Complete |
| German (Deutsch) | `de` | ✅ Complete |
| Spanish (Español) | `es` | ✅ Complete |
| French (Français) | `fr` | ✅ Complete |
| Italian (Italiano) | `it` | ✅ Complete |
### How it works
Language priority (highest to lowest):
1. **Server preference**`preferred_language` returned by `GET /api/mobile/whoami` on login or app resume. Allows a language set on the desktop web interface to propagate to mobile automatically.
2. **AsyncStorage** — the last language explicitly selected on the device, used as an offline fallback when the server is unreachable.
3. **Device locale** — detected via `expo-localization` on first launch.
4. **English** — final fallback when none of the above match a supported locale.
When a user selects a language on mobile the choice is:
- Applied immediately to all screens (via `LocaleContext`)
- Persisted locally to AsyncStorage
- Synced to the server via `POST /api/i18n/language` (fire-and-forget), so the next desktop login reflects the same preference.
> **Note**: If the server's preferred language is not supported by the mobile app (e.g. a locale added to the web frontend but not yet translated for mobile), the mobile app falls back to the next priority in the list above.
### Adding a new language
1. Create a new translation file in `mobile/src/i18n/` (e.g. `pt.json` for Portuguese)
2. Copy the structure from `en.json` and translate all values
3. Import the new file in `mobile/src/i18n/index.ts`
4. Add it to the `translations` object and `getSupportedLanguages()` array
## User Settings
The **Profile** tab includes a **Settings** section where users can:
- **Change language**: Select from the supported languages (English, German, Spanish, French, Italian)
- View server connection details
- Access legal documents (Privacy Policy, Terms of Service, Imprint)
- Sign out or delete their account
## Mobile API Endpoints
The backend exposes a dedicated `/api/mobile/` namespace:
@@ -225,7 +325,8 @@ The backend exposes a dedicated `/api/mobile/` namespace:
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile |
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile (includes `preferred_language`) |
| `POST` | `/api/i18n/language` | Bearer | Sync language preference to server |
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
@@ -269,7 +370,7 @@ Re-registering the same token is safe (idempotent).
### GET /api/mobile/whoami
Returns the current user's profile.
Returns the current user's profile, including the server-stored language preference.
**Response (200):**
```json
@@ -278,10 +379,15 @@ Returns the current user's profile.
"display_name": "John Doe",
"email": "john@example.com",
"avatar_url": "https://www.gravatar.com/avatar/...",
"is_admin": false
"is_admin": false,
"preferred_language": "de"
}
```
`preferred_language` is `null` when no preference has been saved. The mobile
app applies this value on login / app resume, falling back to AsyncStorage and
then the device locale when it is `null` or unsupported.
## Configuration
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
@@ -320,8 +426,20 @@ mobile/
│ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner
│ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
│ ├── FilesScreen.tsx # Processed document list
── ProfileScreen.tsx # User profile + sign out
│ ├── FilesScreen.tsx # Processed document list with search
── FileDetailScreen.tsx # File detail view with processing logs
│ ├── ProfileScreen.tsx # User profile + settings + sign out
│ └── WelcomeScreen.tsx # Pre-login welcome with legal links
├── i18n/ # Localization (i18n)
│ ├── index.ts # i18n module (locale detection, t() function)
│ ├── en.json # English translations
│ ├── de.json # German translations
│ ├── es.json # Spanish translations
│ ├── fr.json # French translations
│ └── it.json # Italian translations
├── utils/
│ ├── mimeTypes.ts # MIME type mapping for file extensions
│ └── normalizeUri.ts # URI normalization for deduplication
└── services/
└── api.ts # DocuElevate REST API client
```
@@ -408,3 +526,4 @@ eas build --platform ios
- [API Documentation](./API.md)
- [Configuration Guide](./ConfigurationGuide.md)
- [Deployment Guide](./DeploymentGuide.md)
- [Apple App Store Compliance Audit](./AppleAppStoreCompliance.md)
+3 -2
View File
@@ -29,9 +29,10 @@ End users authorize their own OneDrive integration from the **Integrations** das
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
2. Create a OneDrive destination integration (or a Watch Folder with `source_type = onedrive`).
3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration.
4. Enter your Azure AD Client ID and Client Secret in the wizard.
4. If the administrator has configured system-wide OneDrive app credentials (`ONEDRIVE_CLIENT_ID` / `ONEDRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Azure AD app. Uncheck the toggle to use custom credentials if needed.
5. Click **Start Authentication Flow**, authorize access via Microsoft, and the refresh token is automatically saved to your personal integration record.
6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
6. After authorization, an interactive **folder browser** lets you select the target folder directly from your OneDrive — no need to manually type folder paths.
7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
> **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple OneDrive integrations with independent tokens.
+39 -16
View File
@@ -34,7 +34,7 @@ Use this checklist to track readiness before going live.
- [ ] **Redis** — Running and accessible only from internal network
- [ ] **Meilisearch** — Running and accessible only from internal network
- [ ] **Worker replicas** — At least 2 workers configured for redundancy
- [ ] **Monitoring**`/api/health` polled by uptime checker
- [ ] **Monitoring**`/api/diagnostic/health` polled by uptime checker
- [ ] **Backups** — Automated backup of database, workdir, and Meilisearch data
- [ ] **Log retention** — Logs shipped to a persistent store or aggregator
- [ ] **Secrets management** — API keys not committed to source control
@@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level:
#### Content-Security-Policy Notes
DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point:
DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point:
```
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
@@ -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.
+56 -7
View File
@@ -2,13 +2,13 @@
DocuElevate ships with first-class support for [Sentry](https://sentry.io) — an open-source observability platform that provides real-time error tracking and performance monitoring.
When a **Sentry DSN** is configured, every unhandled exception in the FastAPI web process and Celery worker is automatically captured and sent to your Sentry project. Performance transactions (request traces, database queries, background task durations) are also recorded, giving you end-to-end visibility into your deployment.
When a **Sentry DSN** is configured, every unhandled exception in the FastAPI web process and Celery worker is automatically captured and sent to your Sentry project. The **Sentry Browser SDK** is also injected into every rendered page, capturing client-side JavaScript errors, browser performance transactions, and (optionally) session replays. Together these give you full-stack, end-to-end visibility into your deployment.
---
## Quick Start
1. **Create a Sentry project** at <https://sentry.io> (or your self-hosted Sentry instance). Choose the **Python** platform.
1. **Create a Sentry project** at <https://sentry.io> (or your self-hosted Sentry instance). Choose the **Python** platform (the same project and DSN are used for both the server and browser SDKs).
2. Copy the **DSN** from *Project → Settings → Client Keys (DSN)*. It looks like:
```
https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
@@ -17,12 +17,14 @@ When a **Sentry DSN** is configured, every unhandled exception in the FastAPI we
```bash
SENTRY_DSN=https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
```
4. Restart DocuElevate. Sentry initialises automatically on startup — you will see a log line confirming activation.
4. Restart DocuElevate. Sentry initialises automatically on startup — you will see a log line confirming activation. The Sentry Browser SDK `<script>` tag is automatically injected into every rendered page.
---
## Environment Variables
### Server-side (Python SDK)
| Variable | Default | Description |
|---|---|---|
| `SENTRY_DSN` | *(empty)* | Sentry DSN URL. **Required** to enable Sentry. Leave unset to disable. |
@@ -31,6 +33,16 @@ When a **Sentry DSN** is configured, every unhandled exception in the FastAPI we
| `SENTRY_PROFILES_SAMPLE_RATE` | `0.0` | Fraction of profiled transactions sent to Sentry (0.0 1.0). Only active when traces > 0. |
| `SENTRY_SEND_DEFAULT_PII` | `false` | Attach PII (IP addresses, user agents) to events. Disable for GDPR / CCPA compliance. |
### Browser SDK (JavaScript)
The Sentry Browser SDK is loaded automatically on every rendered page when `SENTRY_DSN` is set. The DSN is a *public* key in Sentry's security model and is intentionally embedded in client-side code.
| Variable | Default | Description |
|---|---|---|
| `SENTRY_JS_TRACES_SAMPLE_RATE` | `0.0` | Fraction of browser page-loads captured for client-side performance tracing (0.0 1.0). |
| `SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE` | `0.0` | Fraction of sessions recorded by [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) (0.0 1.0). |
| `SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE` | `0.1` | Fraction of error sessions captured with session replay context (0.0 1.0). |
All variables can alternatively be managed through the **Settings → Observability** section of the DocuElevate admin UI. Settings stored in the database are applied before Sentry initialises on every startup, so changes made via the UI take effect after a restart without requiring any changes to environment variables or `.env` files.
---
@@ -45,6 +57,10 @@ SENTRY_ENVIRONMENT=development
SENTRY_TRACES_SAMPLE_RATE=1.0 # Capture every request during development
SENTRY_PROFILES_SAMPLE_RATE=1.0
SENTRY_SEND_DEFAULT_PII=true # OK in dev; disable in production
SENTRY_JS_TRACES_SAMPLE_RATE=1.0 # Capture every browser navigation
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=1.0 # Record every session
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=1.0
```
### Staging
@@ -55,6 +71,10 @@ SENTRY_ENVIRONMENT=staging
SENTRY_TRACES_SAMPLE_RATE=0.5
SENTRY_PROFILES_SAMPLE_RATE=0.0
SENTRY_SEND_DEFAULT_PII=false
SENTRY_JS_TRACES_SAMPLE_RATE=0.5
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.1
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=1.0
```
### Production
@@ -65,6 +85,10 @@ SENTRY_ENVIRONMENT=production
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10 % sampling keeps quota low
SENTRY_PROFILES_SAMPLE_RATE=0.0
SENTRY_SEND_DEFAULT_PII=false # Default — required for GDPR compliance
SENTRY_JS_TRACES_SAMPLE_RATE=0.1
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0 # Disabled; rely on error-triggered replay
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1
```
---
@@ -80,6 +104,8 @@ services:
SENTRY_DSN: "https://<key>@o<org>.ingest.sentry.io/<project>"
SENTRY_ENVIRONMENT: "production"
SENTRY_TRACES_SAMPLE_RATE: "0.1"
SENTRY_JS_TRACES_SAMPLE_RATE: "0.1"
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE: "0.1"
worker:
environment:
@@ -88,6 +114,8 @@ services:
SENTRY_TRACES_SAMPLE_RATE: "0.1"
```
> The `worker` service only needs server-side variables — the Browser SDK runs in the user's browser and is configured via the `api` service.
---
## Kubernetes
@@ -110,6 +138,10 @@ env:
value: production
- name: SENTRY_TRACES_SAMPLE_RATE
value: "0.1"
- name: SENTRY_JS_TRACES_SAMPLE_RATE
value: "0.1"
- name: SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE
value: "0.1"
```
---
@@ -132,17 +164,24 @@ env:
Log messages at `ERROR` level and above are automatically forwarded to Sentry as events. Messages at `INFO` level are recorded as breadcrumbs (contextual trail leading up to an error).
### Browser (JavaScript SDK)
- **Client-side errors** — unhandled JavaScript exceptions and Promise rejections are captured automatically with browser context (URL, user agent, breadcrumbs).
- **Browser performance** — page load, navigation, and resource timing data is captured as Sentry transactions when `SENTRY_JS_TRACES_SAMPLE_RATE > 0`.
- **Session Replay** — screen recordings of user sessions (or just sessions containing errors) can be captured when the relevant replay sample rates are set above `0.0`. Replay data helps reproduce and diagnose hard-to-find UI bugs.
---
## Disabling Sentry
Simply leave `SENTRY_DSN` unset (or set it to an empty string). The SDK is never initialised and no data is sent.
Simply leave `SENTRY_DSN` unset (or set it to an empty string). Neither the Python SDK nor the Browser SDK `<script>` tag is loaded, and no data is sent.
---
## SDK Version
DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,<3.0.0` with the `fastapi`, `celery`, and `sqlalchemy` extras.
- **Server:** DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,<3.0.0` with the `fastapi`, `celery`, and `sqlalchemy` extras.
- **Browser:** The `bundle.tracing.replay.min.js` bundle from the Sentry Browser SDK **v10** is loaded from the official Sentry CDN (`browser.sentry-cdn.com`). The version pin is in `frontend/templates/base.html`.
---
@@ -154,6 +193,13 @@ DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,
2. Check the application logs for the `Sentry initialised` message at startup. If it is absent, the DSN is not being read — confirm the environment variable name is `SENTRY_DSN`, or check the **Settings → Observability** section of the admin UI if you configured it there.
3. Test with `SENTRY_TRACES_SAMPLE_RATE=1.0` so that every request is sent.
### No browser errors in Sentry
1. Confirm `SENTRY_DSN` is set — the Browser SDK `<script>` tag is only injected when the DSN is non-empty.
2. Open browser DevTools → Network and verify that the Sentry CDN bundle (`bundle.tracing.replay.min.js`) loads successfully (HTTP 200).
3. Open DevTools → Console and run `window.Sentry` — it should be an object if the SDK loaded correctly.
4. Check that `SENTRY_JS_TRACES_SAMPLE_RATE` and replay rates are set to values > 0 if you expect performance / replay data (they default to `0.0`).
### `sentry-sdk` import error
```
@@ -164,11 +210,13 @@ Run `pip install 'sentry-sdk[fastapi,celery,sqlalchemy]'` inside your container,
### PII / GDPR concerns
By default `SENTRY_SEND_DEFAULT_PII=false`, which prevents IP addresses and user agents from being attached to events. Review Sentry's [data management documentation](https://docs.sentry.io/product/data-management-settings/) and your organisation's privacy policy before enabling PII.
By default `SENTRY_SEND_DEFAULT_PII=false`, which prevents IP addresses and user agents from being attached to server-side events. Session Replay data may capture user interactions — review [Sentry's privacy documentation](https://docs.sentry.io/product/session-replay/privacy/) and your organisation's privacy policy before enabling replay.
### High Sentry quota usage
Lower `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05` for 5 %) or set it to `0.0` to disable performance tracing entirely.
- Lower `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05` for 5 %) or set it to `0.0` to disable server-side performance tracing.
- Set `SENTRY_JS_TRACES_SAMPLE_RATE=0.0` to disable browser performance tracing.
- Set `SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0` and `SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.0` to disable Session Replay entirely.
---
@@ -179,6 +227,7 @@ Lower `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05` for 5 %) or set it to `0.0` to di
- Use **release tracking**: DocuElevate automatically sets `release` to the current `VERSION` string, enabling you to correlate errors with specific releases.
- Set up **performance baselines** using Sentry's Performance dashboard so that you can detect regressions after deployments.
- Review the [Sentry Python documentation](https://docs.sentry.io/platforms/python/) for advanced configuration options such as custom tags, user context, and scrubbing sensitive data.
- Review the [Sentry Browser SDK documentation](https://docs.sentry.io/platforms/javascript/) for advanced browser SDK configuration.
---
+90 -2
View File
@@ -1,10 +1,10 @@
# Social Login Setup Guide
This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords.
This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox, GitHub) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords.
## Overview
DocuElevate supports four social login providers:
DocuElevate supports five social login providers, plus additional SSO options:
| Provider | Protocol | Best For |
|----------|----------|----------|
@@ -12,9 +12,15 @@ DocuElevate supports four social login providers:
| **Microsoft** | OAuth2 / OpenID Connect | Microsoft 365 / Azure AD organizations and personal Microsoft accounts |
| **Apple** | OAuth2 / OpenID Connect | iOS/macOS users, privacy-focused users |
| **Dropbox** | OAuth2 | Teams already using Dropbox as a storage destination |
| **GitHub** | OAuth2 | Developer teams and open-source organizations |
| **Keycloak** | OpenID Connect | Self-hosted identity management |
| **Generic OAuth2** | OAuth2 | Any OAuth2-compatible identity provider |
| **SAML2** | SAML 2.0 | Enterprise identity providers (Okta, ADFS, etc.) |
Each provider is **independently enabled** — you can use one, several, or all of them at the same time. Social login works alongside any other DocuElevate authentication method (simple auth, OIDC/Authentik, local signup).
> **Tip:** You can also configure providers through the admin **Connections** page at `/admin/connections`, which provides a wizard-like interface for setting up authentication services.
## Prerequisites
Before configuring any social login provider, ensure:
@@ -42,6 +48,7 @@ For example, if your DocuElevate instance is at `https://docuelevate.example.com
| Microsoft | `https://docuelevate.example.com/social-callback/microsoft` |
| Apple | `https://docuelevate.example.com/social-callback/apple` |
| Dropbox | `https://docuelevate.example.com/social-callback/dropbox` |
| GitHub | `https://docuelevate.example.com/social-callback/github` |
---
@@ -257,6 +264,86 @@ docker compose restart api worker
---
## GitHub Sign-In
### 1. Create an OAuth App in GitHub
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click **OAuth Apps** → **New OAuth App**
3. Fill in the required fields:
- **Application name**: `DocuElevate` (or your preferred name)
- **Homepage URL**: `https://docuelevate.example.com`
- **Authorization callback URL**: `https://docuelevate.example.com/social-callback/github`
4. Click **Register application**
5. Copy the **Client ID**
6. Click **Generate a new client secret** and copy the secret immediately (it won't be shown again)
### 2. Configure DocuElevate
Add these environment variables to your `.env` file:
```bash
SOCIAL_AUTH_GITHUB_ENABLED=true
SOCIAL_AUTH_GITHUB_CLIENT_ID=your-github-client-id
SOCIAL_AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
```
### 3. Restart DocuElevate
```bash
docker compose restart api worker
```
### Notes
- **Scopes requested**: `read:user` and `user:email` — DocuElevate only reads the user's public profile and primary email address
- **Organization restrictions**: If your GitHub organization restricts OAuth app access, an organization owner must approve the DocuElevate OAuth app
- **Private email**: If a user's email is private on GitHub, DocuElevate will request it via the `user:email` scope
---
## Keycloak SSO
### 1. Create a Client in Keycloak
1. Log in to your Keycloak admin console
2. Select (or create) a realm
3. Go to **Clients** → **Create client**
4. Set **Client type** to `OpenID Connect`
5. Set **Client ID** (e.g., `docuelevate`)
6. Enable **Client authentication** (confidential)
7. Add `https://docuelevate.example.com/social-callback/keycloak` to **Valid redirect URIs**
8. Save and copy the **Client secret** from the **Credentials** tab
### 2. Configure DocuElevate
```bash
SOCIAL_AUTH_KEYCLOAK_ENABLED=true
SOCIAL_AUTH_KEYCLOAK_CLIENT_ID=docuelevate
SOCIAL_AUTH_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
SOCIAL_AUTH_KEYCLOAK_SERVER_URL=https://keycloak.example.com
SOCIAL_AUTH_KEYCLOAK_REALM=your-realm-name
```
---
## Generic OAuth2 SSO
For any OAuth2-compatible identity provider not listed above:
```bash
SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true
SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_ID=your-client-id
SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_SECRET=your-client-secret
SOCIAL_AUTH_GENERIC_OAUTH2_AUTHORIZE_URL=https://idp.example.com/oauth/authorize
SOCIAL_AUTH_GENERIC_OAUTH2_TOKEN_URL=https://idp.example.com/oauth/token
SOCIAL_AUTH_GENERIC_OAUTH2_USERINFO_URL=https://idp.example.com/oauth/userinfo
SOCIAL_AUTH_GENERIC_OAUTH2_SCOPE=openid profile email
SOCIAL_AUTH_GENERIC_OAUTH2_NAME=My Identity Provider
```
---
## Unified Authentication and Storage
One of the key advantages of social login in DocuElevate is the potential for **unified authentication** — using the same identity for both signing in and accessing cloud storage destinations:
@@ -266,6 +353,7 @@ One of the key advantages of social login in DocuElevate is the potential for **
| Google | Google Drive | User already has a Google identity for Drive integration |
| Microsoft | OneDrive | User already has a Microsoft identity for OneDrive integration |
| Dropbox | Dropbox | User already has a Dropbox identity for Dropbox integration |
| GitHub | *(none)* | Developer-friendly authentication for technical teams |
| Apple | *(none)* | Provides a familiar, privacy-respecting login option |
When a user signs in with a social provider that matches a configured storage destination, the administrator can leverage the same OAuth credentials or simplify the integration setup. Note that the storage integration credentials are configured separately in the admin settings — social login establishes the user's identity, not their storage permissions.
+78 -2
View File
@@ -87,7 +87,7 @@ DocuElevate provides multiple convenient ways to upload documents to the system.
#### Supported File Types
- **Documents**: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx)
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG, HEIC, HEIF
- **Text**: Plain text (.txt), CSV, RTF, HTML, XML, Markdown
- **Maximum file size**: 500MB per file
@@ -582,7 +582,25 @@ Processing pipelines let you define exactly what happens to your documents when
| `embed_metadata` | Write extracted metadata into the PDF document properties |
| `compute_embedding` | Compute semantic embeddings for similarity search |
| `send_to_destinations` | Upload the processed document to all configured storage destinations |
| `classify` | Classify the document type with AI |
| `classify` | Classify the document type using rules (filename patterns, content keywords, metadata) |
#### Classify step rule-based document classification
The `classify` step assigns a category to each document by evaluating **built-in** and **custom** classification rules. Rules are matched against three signals:
- **Filename patterns** — regex matched against the original filename (e.g. `(?i)invoice` matches filenames containing "invoice").
- **Content keywords** — pipe-separated keywords matched against the OCR text (e.g. `invoice number|amount due`).
- **Metadata match**`field=value` matched against existing AI metadata (e.g. `document_type=Invoice`).
**Pre-built categories** include: Invoice, Contract, Receipt, Letter, Report, Bank Statement, Tax Document, Insurance, and Payslip. You can also define your own custom categories.
The classification result is stored in the document's `ai_metadata` under the `classification` key with the matched category, confidence score, and list of matched rules. If no `document_type` was previously set by AI metadata extraction, the classify step will also populate it.
> **Tip:** Manage custom classification rules via **Settings → Classification Rules** or the `/api/classification-rules/` API. See the [API Documentation](./API.md#classification-rules) for details.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `use_builtin_rules` | boolean | `true` | Include the pre-built classification rules |
#### OCR step options
@@ -687,6 +705,64 @@ You can test your rules without actually routing a document using the
**evaluate** endpoint (`POST /api/routing-rules/evaluate`). For the full
API reference, see [API Documentation](API.md#routing-rules).
## Comments & Annotations
The file detail page includes a **collaboration panel** for threaded
comments and PDF annotations, allowing team members to discuss documents
directly within DocuElevate.
### Comments
The **Comments** panel is on the left side of the collaboration section at
the bottom of the file detail page.
#### Viewing Comments
Open any file's detail page (`/files/{id}/detail`). Existing comments load
automatically, displayed in a threaded tree — replies are nested under their
parent.
#### Adding a Comment
1. Type your comment in the text area at the bottom of the Comments panel.
2. Use `@username` to mention another user — an autocomplete dropdown
appears as you type after the `@` symbol. Use arrow keys and Enter to
select a user.
3. Click **Add comment** to post.
#### Replying to a Comment
Click the **Reply** button on any top-level comment. A reply text area
appears inline; type your response and click **Reply** to post.
#### Editing & Deleting
You can edit or delete your own comments using the **Edit** and trash
buttons. Edits re-extract @mentions automatically.
#### Resolving Threads
Click **Resolve** on a top-level comment to mark the thread as resolved
(shown with a green badge). Click **Reopen** to re-open it.
### Annotations
The **Annotations** panel is on the right side of the collaboration
section.
#### Adding an Annotation
1. Type the annotation content in the text area.
2. Set the **Page** number the annotation refers to.
3. Choose a **Type**: Note, Highlight, Underline, or Strikethrough.
4. Pick a **Color** using the color picker.
5. Click **Add annotation** to save.
#### Editing & Deleting
You can edit or delete your own annotations using the pencil and trash
buttons. When editing, you can also change the annotation type.
### @Mention Autocomplete
When typing `@` followed by characters in the comment input, an
autocomplete dropdown shows matching users (sourced from the
`/api/users/mentionable` endpoint). Navigate with arrow keys and press
Enter or click to insert the mention.
## API Access
For programmatic access, DocuElevate provides a comprehensive REST API:
+329
View File
@@ -0,0 +1,329 @@
/* frontend/input.css
* Tailwind CSS v3 source file.
* Edit this file (not static/styles.css) the compiled output is
* generated by running: npm run build (inside the frontend/ directory)
*/
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Custom utilities ─────────────────────────────────────────────────────── */
/* =============================================================
ACCESSIBILITY
Skip-to-content link, focus indicators, and screen-reader-only
utility class following WCAG 2.1 Level AA requirements.
============================================================= */
/* Skip-to-content link: visible only on keyboard focus */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: 9999;
padding: 0.75rem 1.5rem;
background-color: #1d4ed8;
color: #ffffff;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 0.375rem 0;
}
.skip-link:focus {
position: fixed;
top: 0;
left: 0;
width: auto;
height: auto;
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Screen-reader-only utility (visually hidden, accessible to AT) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
body {
/* Your global overrides can go here if needed */
}
.material-symbols-light--folder-managed-outline {
display: inline-block;
width: 96px;
height: 96px;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* Ensure pagination wraps properly on small screens */
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-buttons {
flex-wrap: wrap;
}
/* Ensure filter items stack on very small screens */
@media (max-width: 480px) {
.filter-group {
flex-direction: column;
}
.filter-item {
min-width: unset;
width: 100%;
}
}
/* =============================================================
DARK MODE
Activated by "dark" class on <html> element.
Toggled by the navbar button; preference stored in localStorage.
Falls back to the server-side ui_default_color_scheme setting,
then to the OS prefers-color-scheme media query.
WCAG AA contrast ratios verified for all text/background pairs.
============================================================= */
/* Tell the browser we support both colour schemes */
html { color-scheme: light; }
html.dark { color-scheme: dark; }
/* ---- Base / Body ---- */
html.dark body { background-color: #111827; color: #e5e7eb; }
html.dark .bg-gray-50 { background-color: #111827; }
html.dark .bg-white { background-color: #1f2937; }
html.dark .bg-gray-100 { background-color: #374151; }
html.dark .bg-gray-200 { background-color: #4b5563; }
/* ---- Text colours ---- */
html.dark .text-gray-900 { color: #f9fafb; }
html.dark .text-gray-800 { color: #f3f4f6; }
html.dark .text-gray-700 { color: #e5e7eb; }
html.dark .text-gray-600 { color: #d1d5db; }
html.dark .text-gray-500 { color: #9ca3af; }
html.dark .text-gray-400 { color: #9ca3af; }
html.dark .text-black { color: #f9fafb; }
/* ---- Borders ---- */
html.dark .border-gray-100 { border-color: #374151; }
html.dark .border-gray-200 { border-color: #374151; }
html.dark .border-gray-300 { border-color: #4b5563; }
html.dark .border-gray-400 { border-color: #6b7280; }
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
/* ---- Hover states ---- */
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
/* ---- Shadows (softened for dark mode) ---- */
html.dark .shadow,
html.dark .shadow-md,
html.dark .shadow-sm,
html.dark .shadow-lg {
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
}
/* ---- Alert / info-banner backgrounds ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
html.dark .bg-green-50 { background-color: #052e16; }
html.dark .bg-red-50 { background-color: #450a0a; }
html.dark .bg-yellow-50 { background-color: #451a03; }
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
html.dark .bg-orange-50 { background-color: #431407; }
/* ---- Badge / pill backgrounds ---- */
html.dark .bg-blue-100 { background-color: #1e3a5f; }
html.dark .bg-green-100 { background-color: #052e16; }
html.dark .bg-red-100 { background-color: #450a0a; }
html.dark .bg-yellow-100 { background-color: #451a03; }
html.dark .bg-indigo-100 { background-color: #431407; }
html.dark .bg-orange-100 { background-color: #431407; }
html.dark .bg-purple-100 { background-color: #2e1065; }
/* ---- Status / badge text colours ---- */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-800 { color: #bfdbfe; }
html.dark .text-green-700 { color: #86efac; }
html.dark .text-green-800 { color: #bbf7d0; }
html.dark .text-red-700 { color: #fca5a5; }
html.dark .text-red-800 { color: #fecaca; }
html.dark .text-yellow-700 { color: #fcd34d; }
html.dark .text-yellow-800 { color: #fde68a; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-800 { color: #c7d2fe; }
html.dark .text-orange-700 { color: #fdba74; }
html.dark .text-orange-800 { color: #fed7aa; }
html.dark .text-purple-700 { color: #d8b4fe; }
html.dark .text-purple-800 { color: #e9d5ff; }
/* ---- Dropdown / popup menus ---- */
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
/* ---- Form inputs / selects / textareas ---- */
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select,
html.dark textarea {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #9ca3af;
}
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select:focus,
html.dark textarea:focus {
border-color: #60a5fa;
outline-color: #60a5fa;
}
/* ---- Table rows ---- */
html.dark thead,
html.dark .bg-gray-50 thead { background-color: #1f2937; }
html.dark thead th { color: #9ca3af; }
html.dark tbody tr:hover { background-color: #374151; }
/* ---- Code / pre ---- */
html.dark pre,
html.dark code { background-color: #111827; color: #d1d5db; }
/* ---- Dark-mode toggle button icon colour ---- */
html.dark #darkModeToggle { color: #fbbf24; }
html.dark #darkModeToggle:hover { background-color: #374151; }
/* ---- Dark-mode skip-link ---- */
html.dark .skip-link { background-color: #2563eb; }
html.dark .skip-link:focus { outline-color: #60a5fa; }
/* ---- Dark-mode focus-visible indicators ---- */
html.dark a:focus-visible,
html.dark button:focus-visible,
html.dark input:focus-visible,
html.dark select:focus-visible,
html.dark textarea:focus-visible,
html.dark [tabindex]:focus-visible {
outline-color: #60a5fa;
}
/* ---- Scrollbar (WebKit browsers) ---- */
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* ---- Settings page: sidebar active state (dark) ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
/* =============================================================
DOC-TOGGLE cross-browser toggle switch
Implemented with custom CSS pseudo-elements so the appearance
is consistent across all browsers regardless of Tailwind version.
Usage:
<label class="doc-toggle">
<input type="checkbox" class="sr-only" onchange="...">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 ...">Label text</span>
</label>
============================================================= */
.doc-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.doc-toggle-track {
position: relative;
display: inline-block;
width: 44px;
min-width: 44px;
height: 24px;
background-color: #e5e7eb; /* gray-200 */
border-radius: 9999px;
transition: background-color 0.2s ease-in-out;
flex-shrink: 0;
}
.doc-toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: #ffffff;
border: 1px solid #d1d5db; /* gray-300 */
border-radius: 9999px;
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
transform: translateX(20px);
border-color: #ffffff;
}
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
}
/* Dark mode overrides */
html.dark .doc-toggle-track {
background-color: #374151; /* gray-700 */
}
html.dark .doc-toggle-track::after {
background-color: #ffffff;
border-color: #4b5563; /* gray-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
border-color: #ffffff;
}
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
}
+1017
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "docuelevate-frontend",
"version": "1.0.0",
"description": "Frontend asset compilation for DocuElevate",
"scripts": {
"build": "tailwindcss -i input.css -o static/styles.css --minify",
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}
+307
View File
@@ -0,0 +1,307 @@
// frontend/static/js/annotations.js
// Annotations panel — CRUD for PDF page annotations
(function () {
'use strict';
var _fileId = null;
var _currentUserId = null;
var _i18n = {};
// -------------------------------------------------------------------------
// Initialisation
// -------------------------------------------------------------------------
/**
* Bootstrap the annotations panel.
* @param {number} fileId
* @param {string} currentUserId
* @param {object} i18n
*/
function initAnnotations(fileId, currentUserId, i18n) {
_fileId = fileId;
_currentUserId = currentUserId;
_i18n = i18n || {};
_loadAnnotations();
// Expose reload function so the EmbedPDF viewer init script can refresh the
// list after auto-saving an annotation created inside the viewer.
window._reloadAnnotations = _loadAnnotations;
var form = document.getElementById('annotation-form');
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
_createAnnotation();
});
}
}
// -------------------------------------------------------------------------
// Data fetching
// -------------------------------------------------------------------------
function _loadAnnotations() {
var container = document.getElementById('annotations-list');
if (!container) return;
container.innerHTML = '<div class="annotations-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
fetch('/api/files/' + _fileId + '/annotations')
.then(function (r) { return r.json(); })
.then(function (data) {
_renderAnnotations(data.annotations || [], container);
})
.catch(function () {
container.innerHTML = '<p class="annotations-empty">' + (_i18n.empty || 'No annotations yet') + '</p>';
});
}
// -------------------------------------------------------------------------
// Rendering
// -------------------------------------------------------------------------
function _renderAnnotations(annotations, container) {
container.innerHTML = '';
if (!annotations.length) {
container.innerHTML = '<p class="annotations-empty"><i class="fas fa-sticky-note" aria-hidden="true"></i> ' +
(_i18n.empty || 'No annotations yet') + '</p>';
return;
}
for (var i = 0; i < annotations.length; i++) {
container.appendChild(_buildAnnotationNode(annotations[i]));
}
}
function _buildAnnotationNode(ann) {
var div = document.createElement('div');
div.className = 'annotation-item';
div.setAttribute('data-annotation-id', ann.id);
// Type badge + color indicator
var header = document.createElement('div');
header.className = 'annotation-header';
var typeBadge = document.createElement('span');
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
var pageInfo = document.createElement('button');
pageInfo.type = 'button';
pageInfo.className = 'annotation-page annotation-page--link';
pageInfo.setAttribute('aria-label', (_i18n.go_to_page || 'Go to page') + ' ' + ann.page);
pageInfo.title = (_i18n.go_to_page || 'Go to page') + ' ' + ann.page;
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
(_i18n.page || 'Page') + ' ' + ann.page;
pageInfo.addEventListener('click', function () {
if (typeof window._embedpdfScrollToPage === 'function') {
window._embedpdfScrollToPage(ann.page);
}
});
header.appendChild(typeBadge);
if (ann.color) {
var colorDot = document.createElement('span');
colorDot.className = 'annotation-color-dot';
colorDot.style.backgroundColor = ann.color;
colorDot.setAttribute('aria-label', (_i18n.color || 'Color') + ': ' + ann.color);
header.appendChild(colorDot);
}
header.appendChild(pageInfo);
div.appendChild(header);
// Content
var content = document.createElement('div');
content.className = 'annotation-content';
content.id = 'annotation-content-' + ann.id;
content.textContent = ann.content;
div.appendChild(content);
// Meta
var meta = document.createElement('div');
meta.className = 'annotation-meta';
var author = document.createElement('span');
author.className = 'annotation-author';
author.textContent = ann.user_id;
var time = document.createElement('time');
time.className = 'annotation-time';
time.setAttribute('datetime', ann.created_at);
time.textContent = _formatDate(ann.created_at);
meta.appendChild(author);
meta.appendChild(time);
div.appendChild(meta);
// Actions (author only)
if (ann.user_id === _currentUserId) {
var actions = document.createElement('div');
actions.className = 'annotation-actions';
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'annotation-action-btn';
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i>';
editBtn.setAttribute('aria-label', 'Edit annotation');
editBtn.addEventListener('click', function () { _showEditForm(ann); });
actions.appendChild(editBtn);
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'annotation-action-btn annotation-action-btn--danger';
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
deleteBtn.setAttribute('aria-label', 'Delete annotation');
deleteBtn.addEventListener('click', function () { _deleteAnnotation(ann.id); });
actions.appendChild(deleteBtn);
div.appendChild(actions);
}
return div;
}
function _formatDate(iso) {
if (!iso) return '';
try {
var d = new Date(iso);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch (_e) {
return iso;
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
function _createAnnotation() {
var content = document.getElementById('annotation-content-input');
var page = document.getElementById('annotation-page-input');
var type = document.getElementById('annotation-type-input');
var color = document.getElementById('annotation-color-input');
if (!content || !content.value.trim()) return;
var payload = {
content: content.value.trim(),
page: parseInt(page ? page.value : '1', 10) || 1,
annotation_type: type ? type.value : 'note',
color: color ? color.value : null,
x: 0,
y: 0,
width: 0,
height: 0,
};
fetch('/api/files/' + _fileId + '/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
return r.json();
})
.then(function () {
content.value = '';
if (page) page.value = '1';
_loadAnnotations();
})
.catch(function () {});
}
function _deleteAnnotation(annotationId) {
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this annotation?')) return;
fetch('/api/files/' + _fileId + '/annotations/' + annotationId, {
method: 'DELETE',
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadAnnotations();
})
.catch(function () {});
}
function _showEditForm(ann) {
var contentDiv = document.getElementById('annotation-content-' + ann.id);
if (!contentDiv) return;
if (contentDiv.querySelector('.annotation-edit-form')) return;
var originalText = contentDiv.textContent;
contentDiv.textContent = '';
var form = document.createElement('div');
form.className = 'annotation-edit-form';
var textarea = document.createElement('textarea');
textarea.className = 'annotation-textarea';
textarea.value = ann.content;
textarea.rows = 3;
textarea.setAttribute('aria-label', 'Edit annotation');
var typeSelect = document.createElement('select');
typeSelect.className = 'annotation-select';
typeSelect.setAttribute('aria-label', 'Annotation type');
var types = ['note', 'highlight', 'underline', 'strikethrough'];
for (var i = 0; i < types.length; i++) {
var opt = document.createElement('option');
opt.value = types[i];
opt.textContent = _i18n['type_' + types[i]] || types[i];
if (types[i] === ann.annotation_type) opt.selected = true;
typeSelect.appendChild(opt);
}
var btns = document.createElement('div');
btns.className = 'annotation-edit-btns';
var saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'annotation-submit-btn';
saveBtn.textContent = _i18n.save || 'Save';
saveBtn.addEventListener('click', function () {
var newContent = textarea.value.trim();
if (!newContent) return;
fetch('/api/files/' + _fileId + '/annotations/' + ann.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: newContent,
annotation_type: typeSelect.value,
page: ann.page,
x: ann.x,
y: ann.y,
}),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadAnnotations();
})
.catch(function () {
contentDiv.textContent = originalText;
});
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'annotation-cancel-btn';
cancelBtn.textContent = _i18n.cancel || 'Cancel';
cancelBtn.addEventListener('click', function () {
contentDiv.textContent = originalText;
});
btns.appendChild(saveBtn);
btns.appendChild(cancelBtn);
form.appendChild(textarea);
form.appendChild(typeSelect);
form.appendChild(btns);
contentDiv.appendChild(form);
textarea.focus();
}
// Expose
window.initAnnotations = initAnnotations;
})();
+44
View File
@@ -0,0 +1,44 @@
/**
* claim.js Claim-ownership UI helper for unowned documents.
*
* Usage: call initClaimOwnership(fileId, i18n) after DOMContentLoaded.
* The i18n object must contain:
* confirm, success, failed
*/
function initClaimOwnership(fileId, i18n) {
var btn = document.getElementById('claim-btn');
var msg = document.getElementById('claim-msg');
if (!btn) return;
btn.addEventListener('click', function () {
if (!confirm(i18n.confirm)) return;
btn.disabled = true;
fetch('/api/files/' + fileId + '/claim', { method: 'POST' })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
.then(function (result) {
if (result.ok || (result.data && result.data.status === 'already_owned')) {
if (msg) {
msg.textContent = i18n.success;
msg.style.color = '#059669';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
setTimeout(function () { location.reload(); }, 1200);
} else {
if (msg) {
msg.textContent = (result.data && result.data.detail) || i18n.failed;
msg.style.color = '#dc2626';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
btn.disabled = false;
}
})
.catch(function () {
if (msg) {
msg.textContent = i18n.failed;
msg.style.color = '#dc2626';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
btn.disabled = false;
});
});
}
+462
View File
@@ -0,0 +1,462 @@
// frontend/static/js/comments.js
// Comments panel — threaded comments with @mention autocomplete
(function () {
'use strict';
var _fileId = null;
var _currentUserId = null;
var _i18n = {};
var _mentionableUsers = [];
// -------------------------------------------------------------------------
// Initialisation
// -------------------------------------------------------------------------
/**
* Bootstrap the comments panel.
* @param {number} fileId
* @param {string} currentUserId
* @param {object} i18n
*/
function initComments(fileId, currentUserId, i18n) {
_fileId = fileId;
_currentUserId = currentUserId;
_i18n = i18n || {};
_loadComments();
_loadMentionableUsers();
var form = document.getElementById('comment-form');
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
_submitComment(null);
});
}
var input = document.getElementById('comment-input');
if (input) {
input.addEventListener('input', function () {
_handleMentionInput(this);
});
input.addEventListener('keydown', function (e) {
_handleMentionKeydown(e);
});
// Close dropdown when clicking outside
document.addEventListener('click', function (e) {
var dropdown = document.getElementById('mention-dropdown');
if (dropdown && !dropdown.contains(e.target) && e.target !== input) {
dropdown.classList.add('hidden');
}
});
}
}
// -------------------------------------------------------------------------
// Data fetching
// -------------------------------------------------------------------------
function _loadComments() {
var container = document.getElementById('comments-list');
if (!container) return;
container.innerHTML = '<div class="comments-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
fetch('/api/files/' + _fileId + '/comments')
.then(function (r) { return r.json(); })
.then(function (data) {
_renderComments(data.comments || [], container);
})
.catch(function () {
container.innerHTML = '<p class="comments-error">' + (_i18n.empty || 'No comments yet') + '</p>';
});
}
function _loadMentionableUsers() {
fetch('/api/users/mentionable')
.then(function (r) { return r.json(); })
.then(function (users) {
_mentionableUsers = users || [];
})
.catch(function () {
_mentionableUsers = [];
});
}
// -------------------------------------------------------------------------
// Rendering
// -------------------------------------------------------------------------
function _renderComments(comments, container) {
container.innerHTML = '';
if (!comments.length) {
container.innerHTML = '<p class="comments-empty"><i class="fas fa-comments" aria-hidden="true"></i> ' +
(_i18n.empty || 'No comments yet') + '</p>';
return;
}
for (var i = 0; i < comments.length; i++) {
container.appendChild(_buildCommentNode(comments[i], false));
}
}
function _buildCommentNode(comment, isReply) {
var div = document.createElement('div');
div.className = 'comment-item' + (isReply ? ' comment-reply' : '') +
(comment.is_resolved ? ' comment-resolved' : '');
div.setAttribute('data-comment-id', comment.id);
// Header
var header = document.createElement('div');
header.className = 'comment-header';
var author = document.createElement('span');
author.className = 'comment-author';
author.textContent = comment.user_id;
var time = document.createElement('time');
time.className = 'comment-time';
time.setAttribute('datetime', comment.created_at);
time.textContent = _formatDate(comment.created_at);
header.appendChild(author);
header.appendChild(time);
if (comment.is_resolved) {
var badge = document.createElement('span');
badge.className = 'comment-resolved-badge';
badge.innerHTML = '<i class="fas fa-check-circle" aria-hidden="true"></i> ' + (_i18n.resolved || 'Resolved');
header.appendChild(badge);
}
div.appendChild(header);
// Body
var bodyDiv = document.createElement('div');
bodyDiv.className = 'comment-body';
bodyDiv.id = 'comment-body-' + comment.id;
bodyDiv.innerHTML = _renderMentions(comment.body);
div.appendChild(bodyDiv);
// Actions
var actions = document.createElement('div');
actions.className = 'comment-actions';
// Reply button (only for top-level)
if (!isReply) {
var replyBtn = document.createElement('button');
replyBtn.type = 'button';
replyBtn.className = 'comment-action-btn';
replyBtn.innerHTML = '<i class="fas fa-reply" aria-hidden="true"></i> ' + (_i18n.add_reply || 'Reply');
replyBtn.setAttribute('aria-label', _i18n.add_reply || 'Reply');
replyBtn.addEventListener('click', function () { _showReplyForm(comment.id, div); });
actions.appendChild(replyBtn);
// Resolve / Unresolve
var resolveBtn = document.createElement('button');
resolveBtn.type = 'button';
resolveBtn.className = 'comment-action-btn';
if (comment.is_resolved) {
resolveBtn.innerHTML = '<i class="fas fa-undo" aria-hidden="true"></i> ' + (_i18n.unresolve || 'Reopen');
resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen');
} else {
resolveBtn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i> ' + (_i18n.resolve || 'Resolve');
resolveBtn.setAttribute('aria-label', _i18n.resolve || 'Resolve');
}
resolveBtn.addEventListener('click', function () { _toggleResolve(comment.id, !comment.is_resolved); });
actions.appendChild(resolveBtn);
}
// Edit (author only)
if (comment.user_id === _currentUserId) {
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'comment-action-btn';
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i> ' + (_i18n.edit || 'Edit');
editBtn.setAttribute('aria-label', _i18n.edit || 'Edit');
editBtn.addEventListener('click', function () { _showEditForm(comment.id, comment.body, div); });
actions.appendChild(editBtn);
// Delete
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'comment-action-btn comment-action-btn--danger';
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
deleteBtn.setAttribute('aria-label', 'Delete comment');
deleteBtn.addEventListener('click', function () { _deleteComment(comment.id); });
actions.appendChild(deleteBtn);
}
div.appendChild(actions);
// Replies
if (comment.replies && comment.replies.length) {
var repliesDiv = document.createElement('div');
repliesDiv.className = 'comment-replies';
for (var j = 0; j < comment.replies.length; j++) {
repliesDiv.appendChild(_buildCommentNode(comment.replies[j], true));
}
div.appendChild(repliesDiv);
}
return div;
}
function _renderMentions(text) {
if (!text) return '';
// Escape HTML first
var escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Highlight @mentions
return escaped.replace(/@([\w.\-]+)/g, '<span class="comment-mention">@$1</span>');
}
function _formatDate(iso) {
if (!iso) return '';
try {
var d = new Date(iso);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch (_e) {
return iso;
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
function _submitComment(parentId) {
var inputId = parentId ? 'reply-input-' + parentId : 'comment-input';
var input = document.getElementById(inputId);
if (!input) return;
var body = input.value.trim();
if (!body) return;
var payload = { body: body };
if (parentId) payload.parent_id = parentId;
fetch('/api/files/' + _fileId + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
return r.json();
})
.then(function () {
input.value = '';
_loadComments();
})
.catch(function () {
// Silently fail — the CSRF wrapper in common.js handles token injection
});
}
function _toggleResolve(commentId, resolve) {
fetch('/api/files/' + _fileId + '/comments/' + commentId + '/resolve', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_resolved: resolve }),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadComments();
})
.catch(function () {});
}
function _deleteComment(commentId) {
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this comment?')) return;
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
method: 'DELETE',
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadComments();
})
.catch(function () {});
}
function _showReplyForm(commentId, containerNode) {
// Remove existing reply forms
var existing = containerNode.querySelector('.comment-reply-form');
if (existing) { existing.remove(); return; }
var form = document.createElement('div');
form.className = 'comment-reply-form';
var textarea = document.createElement('textarea');
textarea.id = 'reply-input-' + commentId;
textarea.className = 'comment-textarea';
textarea.placeholder = _i18n.reply_placeholder || 'Write a reply...';
textarea.rows = 2;
textarea.setAttribute('aria-label', _i18n.reply_placeholder || 'Write a reply...');
var submitBtn = document.createElement('button');
submitBtn.type = 'button';
submitBtn.className = 'comment-submit-btn';
submitBtn.textContent = _i18n.add_reply || 'Reply';
submitBtn.addEventListener('click', function () { _submitComment(commentId); });
form.appendChild(textarea);
form.appendChild(submitBtn);
// Insert before the replies section or at end
var repliesDiv = containerNode.querySelector('.comment-replies');
if (repliesDiv) {
containerNode.insertBefore(form, repliesDiv);
} else {
containerNode.appendChild(form);
}
textarea.focus();
}
function _showEditForm(commentId, currentBody, containerNode) {
var bodyDiv = document.getElementById('comment-body-' + commentId);
if (!bodyDiv) return;
// Already editing?
if (bodyDiv.querySelector('.comment-edit-form')) return;
var originalHTML = bodyDiv.innerHTML;
bodyDiv.innerHTML = '';
var form = document.createElement('div');
form.className = 'comment-edit-form';
var textarea = document.createElement('textarea');
textarea.className = 'comment-textarea';
textarea.value = currentBody;
textarea.rows = 3;
textarea.setAttribute('aria-label', _i18n.edit || 'Edit');
var btns = document.createElement('div');
btns.className = 'comment-edit-btns';
var saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'comment-submit-btn';
saveBtn.textContent = _i18n.save || 'Save';
saveBtn.addEventListener('click', function () {
var newBody = textarea.value.trim();
if (!newBody) return;
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: newBody }),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadComments();
})
.catch(function () {
bodyDiv.innerHTML = originalHTML;
});
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'comment-cancel-btn';
cancelBtn.textContent = _i18n.cancel || 'Cancel';
cancelBtn.addEventListener('click', function () {
bodyDiv.innerHTML = originalHTML;
});
btns.appendChild(cancelBtn);
form.appendChild(textarea);
form.appendChild(btns);
bodyDiv.appendChild(form);
textarea.focus();
}
// -------------------------------------------------------------------------
// @mention autocomplete
// -------------------------------------------------------------------------
function _handleMentionInput(input) {
var val = input.value;
var cursorPos = input.selectionStart;
var textBefore = val.substring(0, cursorPos);
var match = textBefore.match(/@([\w.\-]*)$/);
var dropdown = document.getElementById('mention-dropdown');
if (!dropdown) return;
if (!match) {
dropdown.classList.add('hidden');
return;
}
var query = match[1].toLowerCase();
var filtered = _mentionableUsers.filter(function (u) {
return u.user_id.toLowerCase().indexOf(query) !== -1 ||
(u.display_name && u.display_name.toLowerCase().indexOf(query) !== -1);
}).slice(0, 8);
if (!filtered.length) {
dropdown.classList.add('hidden');
return;
}
dropdown.innerHTML = '';
for (var i = 0; i < filtered.length; i++) {
(function (user) {
var item = document.createElement('button');
item.type = 'button';
item.className = 'mention-item';
item.setAttribute('role', 'option');
item.innerHTML = '<span class="mention-user-id">' + _escapeHtml(user.user_id) + '</span>' +
(user.display_name ? '<span class="mention-display-name">' + _escapeHtml(user.display_name) + '</span>' : '');
item.addEventListener('click', function () {
_insertMention(input, match.index, cursorPos, user.user_id);
dropdown.classList.add('hidden');
});
dropdown.appendChild(item);
})(filtered[i]);
}
dropdown.classList.remove('hidden');
}
function _handleMentionKeydown(e) {
var dropdown = document.getElementById('mention-dropdown');
if (!dropdown || dropdown.classList.contains('hidden')) return;
if (e.key === 'Escape') {
dropdown.classList.add('hidden');
e.preventDefault();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
var items = dropdown.querySelectorAll('.mention-item');
var focused = dropdown.querySelector('.mention-item:focus');
var idx = Array.prototype.indexOf.call(items, focused);
if (e.key === 'ArrowDown') {
idx = (idx + 1) % items.length;
} else {
idx = idx <= 0 ? items.length - 1 : idx - 1;
}
items[idx].focus();
} else if (e.key === 'Enter' || e.key === 'Tab') {
var active = dropdown.querySelector('.mention-item:focus');
if (active) {
active.click();
e.preventDefault();
}
}
}
function _insertMention(input, matchStart, cursorPos, userId) {
var before = input.value.substring(0, matchStart);
var after = input.value.substring(cursorPos);
input.value = before + '@' + userId + ' ' + after;
var newPos = matchStart + userId.length + 2;
input.setSelectionRange(newPos, newPos);
input.focus();
}
function _escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// Expose
window.initComments = initComments;
})();
+224
View File
@@ -0,0 +1,224 @@
/**
* sharing.js File sharing management UI.
*
* Renders the current shares for a document and lets the file owner
* add new shares, change roles, or revoke access.
*
* Usage:
* initSharing(fileId, i18n)
*
* The i18n object is expected to contain all keys used below.
*/
/* global fetch */
(function () {
'use strict';
var _fileId = null;
var _i18n = {};
// ── DOM helpers ──────────────────────────────────────────────────────────
function _el(id) {
return document.getElementById(id);
}
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function _t(key) {
return _i18n[key] || key;
}
// ── API helpers ──────────────────────────────────────────────────────────
function _apiUrl(suffix) {
return '/api/files/' + _fileId + suffix;
}
function _fetchShares() {
return fetch(_apiUrl('/shares'), { credentials: 'same-origin' })
.then(function (r) { return r.json(); });
}
function _addShare(userId, role) {
return fetch(_apiUrl('/shares'), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shared_with_user_id: userId, role: role }),
}).then(function (r) {
return r.json().then(function (body) {
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
return body;
});
});
}
function _updateRole(shareId, role) {
return fetch(_apiUrl('/shares/' + shareId), {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role }),
}).then(function (r) {
return r.json().then(function (body) {
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
return body;
});
});
}
function _revokeShare(shareId) {
return fetch(_apiUrl('/shares/' + shareId), {
method: 'DELETE',
credentials: 'same-origin',
}).then(function (r) {
return r.json().then(function (body) {
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
return body;
});
});
}
// ── Render ───────────────────────────────────────────────────────────────
function _renderShares(shares) {
var list = _el('sharing-list');
if (!list) return;
if (!shares || shares.length === 0) {
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('no_shares')) + '</p>';
return;
}
var rows = shares.map(function (s) {
var roleLabel = s.role === 'editor' ? _t('role_editor') : _t('role_viewer');
return (
'<div style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid #f1f5f9;">' +
'<span style="font-size:0.875rem;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;" ' +
'aria-label="' + _esc(_t('user_id_label') + ': ' + (s.display_name || s.user_id)) + '" ' +
'title="' + _esc(s.user_id) + '">' +
_esc(s.display_name || s.user_id) +
'</span>' +
'<select' +
' data-share-id="' + _esc(s.share_id) + '"' +
' class="sharing-role-select"' +
' aria-label="' + _esc(_t('change_role')) + '"' +
' style="padding:0.25rem 0.5rem;border:1px solid #cbd5e1;border-radius:0.25rem;font-size:0.8rem;background:#fff;"' +
'>' +
'<option value="viewer"' + (s.role === 'viewer' ? ' selected' : '') + '>' + _esc(_t('role_viewer')) + '</option>' +
'<option value="editor"' + (s.role === 'editor' ? ' selected' : '') + '>' + _esc(_t('role_editor')) + '</option>' +
'</select>' +
'<button' +
' data-share-id="' + _esc(s.share_id) + '"' +
' class="sharing-revoke-btn"' +
' aria-label="' + _esc(_t('revoke')) + '"' +
' title="' + _esc(_t('revoke')) + '"' +
' style="padding:0.25rem 0.5rem;background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5;border-radius:0.25rem;font-size:0.8rem;cursor:pointer;"' +
'>' +
'<i class="fas fa-user-minus" aria-hidden="true"></i>' +
'</button>' +
'</div>'
);
});
list.innerHTML = rows.join('');
// Role change handlers
list.querySelectorAll('.sharing-role-select').forEach(function (sel) {
sel.addEventListener('change', function () {
var shareId = sel.getAttribute('data-share-id');
var newRole = sel.value;
_updateRole(shareId, newRole)
.then(function () { _loadAndRender(); })
.catch(function (err) { _showError(err.message); });
});
});
// Revoke handlers
list.querySelectorAll('.sharing-revoke-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!window.confirm(_t('revoke_confirm'))) return;
var shareId = btn.getAttribute('data-share-id');
_revokeShare(shareId)
.then(function () { _loadAndRender(); })
.catch(function (err) { _showError(err.message); });
});
});
}
function _loadAndRender() {
var list = _el('sharing-list');
if (!list) return;
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('loading')) + '</p>';
_fetchShares()
.then(function (data) {
// GET /files/{id}/shares returns an array; /files/{id}/shared-with also returns array
var shares = Array.isArray(data) ? data : (data.shares || []);
// Normalise keys: shares list uses share_id, but the shares endpoint returns id
shares = shares.map(function (s) {
return {
share_id: s.share_id || s.id,
user_id: s.user_id || s.shared_with_user_id,
display_name: s.display_name || s.shared_with_user_id || s.user_id,
role: s.role,
};
});
_renderShares(shares);
})
.catch(function (err) {
if (list) list.innerHTML = '<p style="color:#ef4444;font-size:0.875rem;">' + _esc(err.message) + '</p>';
});
}
function _showError(msg) {
var el = _el('sharing-form-error');
if (!el) return;
el.textContent = msg;
el.style.display = 'block';
setTimeout(function () { el.style.display = 'none'; }, 5000);
}
// ── Init ─────────────────────────────────────────────────────────────────
function initSharing(fileId, i18n) {
_fileId = fileId;
_i18n = i18n || {};
_loadAndRender();
var form = _el('sharing-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var userInput = _el('share-user-input');
var roleInput = _el('share-role-input');
var userId = userInput ? userInput.value.trim() : '';
var role = roleInput ? roleInput.value : 'viewer';
if (!userId) {
_showError(_t('error_empty_user'));
return;
}
_addShare(userId, role)
.then(function () {
if (userInput) userInput.value = '';
_loadAndRender();
})
.catch(function (err) { _showError(err.message); });
});
}
// Expose
window.initSharing = initSharing;
})();
+8
View File
@@ -433,9 +433,17 @@ function _uploadSingleFile(file, progressBar, statusEl, onTerminal) {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.width = '100%';
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 });
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./static/js/**/*.js',
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
+439
View File
@@ -0,0 +1,439 @@
{% extends "base.html" %}
{% block title %}{{ _("connections.title") }} - {{ _("app.name") }}{% endblock %}
{% block content %}
<main id="main-content" class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("connections.title") }}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _("connections.description") }}</p>
</div>
<!-- SSO Auto Login Section -->
{% if oauth_configured %}
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.sso_auto_login_title") }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
</div>
<div>
<label class="doc-toggle">
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only"
{% if sso_auto_login %}checked{% endif %}
onchange="toggleSetting('sso_auto_login', this.checked)">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.sso_auto_login") }}</span>
</label>
</div>
</div>
</section>
{% endif %}
<!-- Mobile Phone Upload Section -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.mobile_upload_title") }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.mobile_upload_description") }}</p>
{% if not frontend_url_configured %}
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
{{ _("connections.frontend_url_note") }}
</p>
{% endif %}
</div>
<div>
<label class="doc-toggle">
<input type="checkbox" id="qr-upload-toggle" class="sr-only"
{% if qr_login_enabled %}checked{% endif %}
onchange="toggleSetting('qr_login_enabled', this.checked)">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
</label>
</div>
</div>
</section>
<!-- Services Section -->
<section class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("connections.unlinked_services") }}</h2>
<div class="space-y-3">
{% for service in services %}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden border border-gray-100 dark:border-gray-700">
<div class="p-5 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<i class="{{ service.icon }} text-lg" aria-hidden="true"></i>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">{{ service.name }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ service.description }}</p>
</div>
</div>
<div class="flex items-center gap-3">
{% if service.linked %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("connections.linked") }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{{ _("connections.unlinked") }}
</span>
{% endif %}
<button
type="button"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:44px; min-width:44px"
onclick="openServiceModal('{{ service.key }}')"
aria-label="{{ _('connections.configure') }} {{ service.name }}"
>
<i class="fas fa-cog mr-2" aria-hidden="true"></i>
{{ _("connections.configure") }}
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</section>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-4">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
{{ _("connections.save_note") }}
</p>
</main>
<!-- Service Configuration Modal -->
<div id="service-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 hidden" role="dialog" aria-modal="true" aria-labelledby="service-modal-title" aria-describedby="service-modal-description">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]" onclick="event.stopPropagation()">
<div class="px-6 pt-6 pb-2 flex items-center justify-between border-b border-gray-200 dark:border-gray-700">
<div>
<h2 id="service-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white"></h2>
<p id="service-modal-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.save_note") }}</p>
</div>
<button type="button" onclick="closeServiceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded-md p-1" style="min-height:44px; min-width:44px" aria-label="Close">
<i class="fas fa-times text-xl" aria-hidden="true"></i>
</button>
</div>
<form id="service-form" onsubmit="saveServiceSettings(event)">
<div id="service-fields" class="px-6 py-4 space-y-4">
<!-- Dynamically populated -->
</div>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button type="button" onclick="closeServiceModal()" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700" style="min-height:44px;">
{{ _("common.cancel") }}
</button>
<button type="submit" class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" style="min-height:44px;">
<i class="fas fa-save mr-2" aria-hidden="true"></i>
{{ _("common.save") }}
</button>
</div>
</form>
</div>
</div>
<!-- Accessible notification banner -->
<div id="save-notification" class="fixed top-4 right-4 z-[60] hidden max-w-sm" role="alert" aria-live="assertive">
<div id="save-notification-inner" class="rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2">
<i id="save-notification-icon" class="fas" aria-hidden="true"></i>
<span id="save-notification-text"></span>
</div>
</div>
<script>
const serviceSettings = {{ service_settings | tojson }};
const settingMetadata = {};
{% for svc_key, svc_fields in service_settings.items() %}
{% for field in svc_fields %}
settingMetadata['{{ field.key }}'] = {{ field.metadata | tojson }};
{% endfor %}
{% endfor %}
let currentServiceKey = null;
function openServiceModal(serviceKey) {
currentServiceKey = serviceKey;
const fields = serviceSettings[serviceKey] || [];
const modal = document.getElementById('service-modal');
const title = document.getElementById('service-modal-title');
const container = document.getElementById('service-fields');
// Find service name from the page
const serviceCards = document.querySelectorAll('[onclick*="' + serviceKey + '"]');
let serviceName = serviceKey;
if (serviceCards.length > 0) {
const card = serviceCards[0].closest('.bg-white, .dark\\:bg-gray-800');
if (card) {
const h3 = card.querySelector('h3');
if (h3) serviceName = h3.textContent.trim();
}
}
title.textContent = '{{ _("connections.configure") }} ' + serviceName;
container.innerHTML = '';
fields.forEach(function(field) {
const meta = field.metadata || {};
const div = document.createElement('div');
div.setAttribute('data-field-key', field.key);
if (meta.type === 'boolean') {
// For boolean fields use a plain paragraph for the title so the toggle's
// own <label> wrapper is the only interactive label (no nested <label>s).
const titleEl = document.createElement('p');
titleEl.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
titleEl.textContent = field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
div.appendChild(titleEl);
} else {
const label = document.createElement('label');
label.setAttribute('for', 'field-' + field.key);
label.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
label.textContent = field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
div.appendChild(label);
}
if (meta.description) {
const desc = document.createElement('p');
desc.className = 'text-xs text-gray-500 dark:text-gray-400 mb-2';
desc.textContent = meta.description;
div.appendChild(desc);
}
if (meta.type === 'boolean') {
// Styled toggle switch using .doc-toggle CSS class (compatible with Tailwind v2 CDN).
const toggleWrapper = document.createElement('label');
toggleWrapper.className = 'doc-toggle';
toggleWrapper.setAttribute('aria-label', field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }));
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'field-' + field.key;
checkbox.name = field.key;
checkbox.className = 'sr-only';
const val = field.value;
if (val === true || val === 'true' || val === '1' || val === 'True') {
checkbox.checked = true;
}
const slider = document.createElement('span');
slider.className = 'doc-toggle-track';
slider.setAttribute('aria-hidden', 'true');
toggleWrapper.appendChild(checkbox);
toggleWrapper.appendChild(slider);
div.appendChild(toggleWrapper);
// When the Dropbox "use global credentials" toggle changes, update the
// visibility of the separate client-id / client-secret fields.
if (field.key === 'social_auth_dropbox_use_global_credentials') {
checkbox.addEventListener('change', function() {
updateDropboxCredentialFieldsVisibility(this.checked);
});
}
// When the Google "use global credentials" toggle changes, update visibility.
if (field.key === 'social_auth_google_use_global_credentials') {
checkbox.addEventListener('change', function() {
updateGoogleCredentialFieldsVisibility(this.checked);
});
}
// When the Microsoft "use global credentials" toggle changes, update visibility.
if (field.key === 'social_auth_microsoft_use_global_credentials') {
checkbox.addEventListener('change', function() {
updateMicrosoftCredentialFieldsVisibility(this.checked);
});
}
} else {
const input = document.createElement('input');
input.type = meta.sensitive ? 'password' : 'text';
input.id = 'field-' + field.key;
input.name = field.key;
input.className = 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-sm';
input.placeholder = meta.sensitive ? '••••••••' : '';
// Don't pre-fill sensitive values with masked data
if (!meta.sensitive && field.value != null) {
input.value = field.value;
}
div.appendChild(input);
}
if (meta.help_link) {
const link = document.createElement('a');
link.href = meta.help_link;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'text-xs text-indigo-600 hover:text-indigo-500 mt-1 inline-block';
link.textContent = meta.help_link_label || 'Documentation';
div.appendChild(link);
}
container.appendChild(div);
});
// Apply initial visibility for Dropbox credential fields based on the
// current value of the "use global credentials" toggle.
if (currentServiceKey === 'dropbox') {
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_dropbox_use_global_credentials'; });
if (useGlobalField) {
const val = useGlobalField.value;
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
updateDropboxCredentialFieldsVisibility(isGlobal);
}
}
// Apply initial visibility for Google credential fields.
if (currentServiceKey === 'google') {
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_google_use_global_credentials'; });
if (useGlobalField) {
const val = useGlobalField.value;
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
updateGoogleCredentialFieldsVisibility(isGlobal);
}
}
// Apply initial visibility for Microsoft credential fields.
if (currentServiceKey === 'microsoft') {
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_microsoft_use_global_credentials'; });
if (useGlobalField) {
const val = useGlobalField.value;
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
updateMicrosoftCredentialFieldsVisibility(isGlobal);
}
}
modal.classList.remove('hidden');
// Focus first input
setTimeout(function() {
const firstInput = container.querySelector('input');
if (firstInput) firstInput.focus();
}, 100);
}
function closeServiceModal() {
document.getElementById('service-modal').classList.add('hidden');
currentServiceKey = null;
}
function saveServiceSettings(event) {
event.preventDefault();
if (!currentServiceKey) return;
const fields = serviceSettings[currentServiceKey] || [];
const updates = {};
fields.forEach(function(field) {
const meta = field.metadata || {};
const el = document.getElementById('field-' + field.key);
if (!el) return;
if (meta.type === 'boolean') {
updates[field.key] = el.checked ? 'true' : 'false';
} else {
// Only send non-empty values for sensitive fields (empty = no change)
if (meta.sensitive && !el.value) return;
updates[field.key] = el.value;
}
});
// Save each setting via the API
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const promises = Object.entries(updates).map(function([key, value]) {
return fetch('/api/settings/' + key, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ value: value }),
});
});
Promise.all(promises)
.then(function(responses) {
const allOk = responses.every(function(r) { return r.ok; });
if (allOk) {
closeServiceModal();
location.reload();
} else {
showNotification('error', 'Some settings failed to save. Please check the values and try again.');
}
})
.catch(function(err) {
console.error('Error saving settings:', err);
showNotification('error', 'Failed to save settings. Please try again.');
});
}
// Show or hide the Dropbox client-id / client-secret fields depending on
// whether the "use global credentials" toggle is enabled.
function updateDropboxCredentialFieldsVisibility(useGlobal) {
['social_auth_dropbox_client_id', 'social_auth_dropbox_client_secret'].forEach(function(key) {
const el = document.querySelector('[data-field-key="' + key + '"]');
if (el) {
el.style.display = useGlobal ? 'none' : '';
}
});
}
// Show or hide the Google client-id / client-secret fields depending on
// whether the "use global credentials" toggle is enabled.
function updateGoogleCredentialFieldsVisibility(useGlobal) {
['social_auth_google_client_id', 'social_auth_google_client_secret'].forEach(function(key) {
const el = document.querySelector('[data-field-key="' + key + '"]');
if (el) {
el.style.display = useGlobal ? 'none' : '';
}
});
}
// Show or hide the Microsoft client-id / client-secret fields depending on
// whether the "use global credentials" toggle is enabled.
function updateMicrosoftCredentialFieldsVisibility(useGlobal) {
['social_auth_microsoft_client_id', 'social_auth_microsoft_client_secret'].forEach(function(key) {
const el = document.querySelector('[data-field-key="' + key + '"]');
if (el) {
el.style.display = useGlobal ? 'none' : '';
}
});
}
function toggleSetting(key, value) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('/api/settings/' + key, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ value: value ? 'true' : 'false' }),
}).catch(function(err) {
console.error('Error toggling setting:', err);
});
}
// Close modal on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeServiceModal();
});
// Close modal on backdrop click
document.getElementById('service-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeServiceModal();
});
function showNotification(type, message) {
var el = document.getElementById('save-notification');
var inner = document.getElementById('save-notification-inner');
var icon = document.getElementById('save-notification-icon');
var text = document.getElementById('save-notification-text');
if (!el) return;
text.textContent = message;
if (type === 'error') {
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
icon.className = 'fas fa-exclamation-circle';
} else {
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
icon.className = 'fas fa-check-circle';
}
el.classList.remove('hidden');
setTimeout(function() { el.classList.add('hidden'); }, 5000);
}
</script>
{% endblock %}
+117 -8
View File
@@ -33,6 +33,20 @@
aria-required="true"
/>
</div>
<div class="sm:w-44">
<label for="token-lifetime" class="sr-only">{{ _("api_tokens.expires_in_days_label") }}</label>
<input
id="token-lifetime"
type="number"
x-model.number="newTokenExpiresDays"
placeholder="{{ _('api_tokens.expires_at_placeholder') }}"
min="1"
max="3650"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
aria-label="{{ _('api_tokens.expires_in_days_label') }}"
/>
</div>
<button
type="submit"
:disabled="creating || !newTokenName.trim()"
@@ -143,6 +157,7 @@
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_created") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_used") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_ip") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_expires") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("common.status") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
</tr>
@@ -162,28 +177,62 @@
<code x-show="token.last_used_ip" class="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono" x-text="token.last_used_ip"></code>
<span x-show="!token.last_used_ip" class="text-gray-400"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
<span x-text="token.expires_at ? formatDate(token.expires_at) : '{{ _('api_tokens.expires_never') }}'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="token.is_active ? '{{ _('api_tokens.status_active') }}' : '{{ _('api_tokens.status_revoked') }}'"
:class="tokenStatusClass(token)"
x-text="tokenStatusLabel(token)"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<!-- Revoke button shown for active tokens -->
<button
x-show="token.is_active"
type="button"
@click="revokeToken(token)"
:disabled="revoking === token.id"
:disabled="acting === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('api_tokens.revoke_prefix') }} ' + token.name"
>
<i :class="revoking === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-ban'" class="mr-1" aria-hidden="true"></i>
{{ _("api_tokens.revoke") }}
</button>
<!-- Reactivate button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="reactivateToken(token)"
:disabled="acting === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-600 hover:text-green-800
dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 transition-colors mr-1"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('api_tokens.reactivate_prefix') }} ' + token.name"
>
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-redo'" class="mr-1" aria-hidden="true"></i>
{{ _("api_tokens.reactivate") }}
</button>
<!-- Delete button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="deleteToken(token)"
:disabled="acting === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('api_tokens.delete_prefix') }} ' + token.name"
>
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
{{ _("api_tokens.delete") }}
</button>
</td>
</tr>
</template>
@@ -209,9 +258,10 @@ function apiTokens() {
tokens: [],
loading: true,
creating: false,
revoking: null,
acting: null,
error: null,
newTokenName: '',
newTokenExpiresDays: null,
newlyCreatedToken: null,
copied: false,
baseUrl: window.location.origin,
@@ -238,13 +288,15 @@ function apiTokens() {
this.error = null;
this.newlyCreatedToken = null;
try {
const body = { name: this.newTokenName.trim() };
if (this.newTokenExpiresDays) body.expires_in_days = parseInt(this.newTokenExpiresDays);
const res = await fetch('/api/api-tokens/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ name: this.newTokenName.trim() }),
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -253,6 +305,7 @@ function apiTokens() {
const data = await res.json();
this.newlyCreatedToken = data.token;
this.newTokenName = '';
this.newTokenExpiresDays = null;
await this.loadTokens();
} catch (e) {
this.error = e.message;
@@ -263,7 +316,7 @@ function apiTokens() {
async revokeToken(token) {
if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return;
this.revoking = token.id;
this.acting = token.id;
this.error = null;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
@@ -278,10 +331,66 @@ function apiTokens() {
} catch (e) {
this.error = e.message;
} finally {
this.revoking = null;
this.acting = null;
}
},
async reactivateToken(token) {
if (!confirm({{ _("api_tokens.reactivate_confirm") | tojson }})) return;
this.acting = token.id;
this.error = null;
try {
const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to reactivate token');
}
await this.loadTokens();
} catch (e) {
this.error = e.message;
} finally {
this.acting = null;
}
},
async deleteToken(token) {
if (!confirm({{ _("api_tokens.delete_confirm") | tojson }})) return;
this.acting = token.id;
this.error = null;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to delete token');
}
await this.loadTokens();
} catch (e) {
this.error = e.message;
} finally {
this.acting = null;
}
},
tokenStatusClass(token) {
if (!token.is_active) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
if (token.expires_at && new Date(token.expires_at) < new Date())
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
},
tokenStatusLabel(token) {
if (!token.is_active) return '{{ _("api_tokens.status_revoked") }}';
if (token.expires_at && new Date(token.expires_at) < new Date())
return '{{ _("api_tokens.status_expired") }}';
return '{{ _("api_tokens.status_active") }}';
},
copyToken() {
if (this.newlyCreatedToken) {
navigator.clipboard.writeText(this.newlyCreatedToken);
+34 -2
View File
@@ -21,8 +21,6 @@
<!-- Alpine.js moved to head for earlier loading -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
{% block head_css %}
<!-- Tailwind CSS and other CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
@@ -34,6 +32,34 @@
{% block head_extra %}{% endblock %}
<!-- CSRF token for AJAX/fetch requests -->
<meta name="csrf-token" content="{{ csrf_token | default('', true) }}">
{# ── Sentry Browser SDK ─────────────────────────────────────────────────
Loaded only when SENTRY_DSN is configured. The DSN is a *public*
Sentry key and is intentionally embedded in client-side code.
Update the version pin at browser.sentry-cdn.com/releases when
upgrading the SDK.
──────────────────────────────────────────────────────────────────────── #}
{% if sentry_dsn %}
<script src="https://browser.sentry-cdn.com/10.45.0/bundle.tracing.replay.feedback.logs.metrics.min.js"
integrity="sha384-TCY3xw5Ej940LIWfS6PwhCCBl7lvEsxBpHy+BirF+EycSQUvXbfZsgsLi0oU18yZ"
crossorigin="anonymous"
></script>
<script>
if (window.Sentry) {
Sentry.init({
dsn: {{ sentry_dsn | tojson }},
environment: {{ sentry_environment | default("production") | tojson }},
release: {{ version | default(None) | tojson }},
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
tracesSampleRate: {{ sentry_js_traces_sample_rate | default(0.0) }},
replaysSessionSampleRate: {{ sentry_js_replay_session_sample_rate | default(0.0) }},
replaysOnErrorSampleRate: {{ sentry_js_replay_on_error_sample_rate | default(0.1) }},
});
}
</script>
{% endif %}
</head>
<body class="bg-gray-50 min-h-screen flex flex-col"
@@ -155,6 +181,9 @@
<a href="/admin/credentials" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> {{ _("nav.credentials") }}
</a>
<a href="/admin/connections" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-plug w-4 mr-2 text-purple-500" aria-hidden="true"></i> {{ _("nav.connections") }}
</a>
<a href="/admin/files" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.file_manager") }}
</a>
@@ -429,6 +458,9 @@
<a href="/admin/credentials" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> {{ _("nav.credentials") }}
</a>
<a href="/admin/connections" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-plug mr-2 text-purple-400" aria-hidden="true"></i> {{ _("nav.connections") }}
</a>
<a href="/admin/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.file_manager") }}
</a>
+135 -27
View File
@@ -3,7 +3,7 @@
{% block title %}{{ _("devices.page_title") }}{% endblock %}
{% block content %}
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-4xl">
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-6xl">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<header class="mb-8">
@@ -48,55 +48,85 @@
<table class="w-full text-sm" aria-label="{{ _('devices.mobile_tokens_heading') }}">
<thead>
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="token in mobileTokens" :key="token.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2">
<i class="fas fa-mobile-alt text-gray-400" aria-hidden="true"></i>
<span class="font-medium text-gray-900 dark:text-white" x-text="formatDeviceName(token.name)"></span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-4 py-3 whitespace-nowrap">
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span x-text="token.last_used_at ? formatDate(token.last_used_at) : '—'"></span>
<span x-show="token.last_used_ip" class="block text-xs text-gray-400 mt-0.5">
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="token.last_used_ip"></span>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="token.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_revoked') }}'"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<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="revokingToken === token.id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
: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"
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") }}
<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;
}
},
+64 -2
View File
@@ -104,7 +104,7 @@
<h3 class="text-xl font-medium mb-4">Step 3: Set OAuth 2 Redirect URI</h3>
<ol class="list-decimal ml-6 space-y-3">
<li>In your app's settings page, go to the "OAuth 2" section</li>
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback</code></li>
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ callback_url }}</code></li>
<li>Click "Add" to save the redirect URI</li>
</ol>
</div>
@@ -116,6 +116,34 @@
</h2>
<div class="space-y-4">
{% if user_mode and global_creds_available %}
<!-- Global credentials mode: no app credentials required from the user -->
<div class="bg-blue-50 border border-blue-200 rounded-md p-4" role="note">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<div>
<p class="text-sm font-medium text-blue-800">Using shared application credentials</p>
<p class="text-sm text-blue-700 mt-1">Your administrator has enabled shared Dropbox app credentials. You can authorize your account without supplying your own App Key and Secret.</p>
</div>
</div>
</div>
{% if folder_path %}
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
<p class="text-xs text-gray-500">Target folder (from integration settings)</p>
<p class="text-sm font-mono text-gray-700">{{ folder_path }}</p>
</div>
{% endif %}
<div>
<button id="start-auth-flow-global" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>
Authorize with Dropbox
</button>
</div>
{% else %}
<div>
<label for="app-key" class="block text-sm font-medium text-gray-700">App Key <span class="text-red-500" aria-hidden="true">*</span></label>
<input type="text" id="app-key" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Dropbox app key" value="{{ app_key_value }}">
@@ -146,6 +174,7 @@
Start Authentication Flow
</button>
</div>
{% endif %}
<!-- Token validation and status (admin mode only) -->
{% if not user_mode %}
@@ -268,6 +297,10 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
<script>
document.addEventListener('DOMContentLoaded', function() {
const userMode = {{ 'true' if user_mode else 'false' }};
const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }};
// Redirect URI for OAuth: prefer server-provided value (respects PUBLIC_BASE_URL),
// fall back to window.location.origin for resilience.
const dropboxCallbackUrl = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
// Store integration_id if provided (for per-user OAuth flow)
const integrationId = "{{ integration_id or '' }}";
@@ -277,6 +310,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Elements
const startAuthFlowBtn = document.getElementById('start-auth-flow');
const startAuthFlowGlobalBtn = document.getElementById('start-auth-flow-global');
const testTokenBtn = document.getElementById('test-token');
const refreshTokenBtn = document.getElementById('refresh-token-btn');
const tokenStatus = document.getElementById('token-status');
@@ -328,11 +362,38 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Global-credentials "Authorize with Dropbox" button (user mode, admin-provided creds)
if (startAuthFlowGlobalBtn) {
startAuthFlowGlobalBtn.addEventListener('click', async function() {
startAuthFlowGlobalBtn.disabled = true;
startAuthFlowGlobalBtn.innerHTML = '<span class="animate-spin inline-block mr-2"></span> Redirecting…';
try {
const resp = await fetch('/api/dropbox/global-authorize-url');
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showModal('error', 'Error', err.detail || 'Could not retrieve authorization URL.');
startAuthFlowGlobalBtn.disabled = false;
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
return;
}
const data = await resp.json();
// Signal to the callback that global credentials should be used for the exchange
sessionStorage.setItem('dropbox_use_global_creds', 'true');
window.location.href = data.authorize_url;
} catch (err) {
showModal('error', 'Network Error', err.message || 'Unknown error');
startAuthFlowGlobalBtn.disabled = false;
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
}
});
}
// Start Authentication Flow button click
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) {
+248 -8
View File
@@ -59,6 +59,40 @@
</div>
</div>
<!-- Folder selector (shown after authorization) -->
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
<h3 class="font-medium text-lg mb-3">
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
</svg>
Select Folder
</h3>
<p class="text-sm text-gray-600 mb-3">Browse your Dropbox to select a folder for this integration.</p>
<div class="mb-3">
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
</div>
</div>
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
<div class="p-4 text-center text-gray-500">
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
<p class="text-sm">Loading folders…</p>
</div>
</div>
<div class="mt-3 flex items-center gap-3">
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save Folder
</button>
</div>
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
</div>
<div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3">
@@ -94,6 +128,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 +142,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 (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);
}
} 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);
@@ -173,18 +288,21 @@ document.addEventListener('DOMContentLoaded', function() {
}
return response.json();
}).then(() => {
// Clean up
// Clean up session storage
sessionStorage.removeItem('dropbox_app_key');
sessionStorage.removeItem('dropbox_app_secret');
sessionStorage.removeItem('dropbox_folder_path');
sessionStorage.removeItem('oauth_integration_id');
sessionStorage.removeItem('dropbox_use_system_creds');
// Show brief success then redirect to integrations
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>';
// Hide processing spinner, show success
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
document.getElementById('processing-message').innerHTML =
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>';
document.getElementById('success-container').classList.remove('hidden');
// Show folder browser with the access token
initFolderBrowser(data.access_token, integrationId);
});
}
@@ -279,6 +397,128 @@ DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`;
});
}
}
// ── Folder browser ────────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function initFolderBrowser(accessToken, integrationId) {
const folderSelector = document.getElementById('folder-selector');
if (!folderSelector || !integrationId) return;
folderSelector.classList.remove('hidden');
let currentPath = '';
const folderList = document.getElementById('folder-list');
const breadcrumb = document.getElementById('folder-breadcrumb');
const selectedInput = document.getElementById('selected-folder-path');
const saveBtn = document.getElementById('save-folder-btn');
const saveStatus = document.getElementById('folder-save-status');
function loadFolders(path) {
currentPath = path;
folderList.innerHTML = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
const formData = new FormData();
formData.append('access_token', accessToken);
formData.append('path', path);
fetch('/api/dropbox/list-folders', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.folders && data.folders.length > 0) {
folderList.innerHTML = data.folders.map(f =>
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
`<svg class="h-5 w-5 text-yellow-400 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="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
`</button>`
).join('');
folderList.querySelectorAll('.folder-item').forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.getAttribute('data-path');
selectedInput.value = p;
loadFolders(p);
});
});
} else {
folderList.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
}
updateBreadcrumb(path);
})
.catch(err => {
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
});
}
function updateBreadcrumb(path) {
const parts = path.split('/').filter(Boolean);
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
let accumulated = '';
for (const part of parts) {
accumulated += '/' + part;
html += `<span class="mx-1 text-gray-400">/</span>`;
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated)}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
}
breadcrumb.innerHTML = html;
breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.getAttribute('data-path');
selectedInput.value = p || '/';
loadFolders(p);
});
});
}
// Save selected folder to integration config
saveBtn.addEventListener('click', () => {
const folderPath = selectedInput.value.trim() || '/';
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
// Get the current integration config, update folder_path, then PUT back
fetch(`/api/integrations/${integrationId}`)
.then(r => r.json())
.then(intg => {
const cfg = intg.config || {};
// Update the correct folder key based on integration type
if (cfg.source_type) {
cfg.folder_path = folderPath;
} else {
cfg.folder = folderPath;
}
return fetch(`/api/integrations/${integrationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: cfg }),
});
})
.then(r => {
if (!r.ok) throw new Error('Failed to save folder');
return r.json();
})
.then(() => {
saveStatus.textContent = '✓ Folder saved! Redirecting…';
saveStatus.className = 'mt-2 text-sm text-green-600';
saveStatus.classList.remove('hidden');
saveBtn.textContent = 'Saved ✓';
setTimeout(() => { window.location.href = '/integrations'; }, 1500);
})
.catch(err => {
saveStatus.textContent = 'Error: ' + err.message;
saveStatus.className = 'mt-2 text-sm text-red-600';
saveStatus.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Folder';
});
});
// Load root folders initially
loadFolders('');
}
});
</script>
{% endblock %}
+945
View File
@@ -0,0 +1,945 @@
{% extends "base.html" %}
{% block title %}Comments & Annotations - {{ file.original_filename or 'Document' }} - DocuElevate{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
<style>
.annotations-container { max-width: 1200px; margin: 0 auto; }
/* ── PDF viewer ── */
.pdf-viewer-card {
background: white; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
margin-bottom: 1.25rem; overflow: hidden;
}
.dark .pdf-viewer-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.pdf-viewer-card-header {
display: flex; align-items: center; gap: 0.5rem;
padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb;
font-weight: 700; font-size: 0.95rem; color: #374151;
}
.dark .pdf-viewer-card-header { border-bottom-color: #374151; color: #d1d5db; }
#embedpdf-viewer { width: 100%; height: 600px; }
/* ── header ── */
.annotations-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
}
.annotations-title { font-size: 1.5rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
.annotations-subtitle { font-size: 0.85rem; color: #6b7280; margin-top: 0.25rem; }
.dark .annotations-title { color: #f3f4f6; }
.dark .annotations-subtitle { color: #9ca3af; }
/* ── error ── */
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
/* ── card ── */
.collab-card {
background: white; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
padding: 1.5rem; margin-bottom: 1.25rem;
}
.dark .collab-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
/* ── Comments panel ───────────────────────────────────────────────────── */
.comments-panel, .annotations-panel {
margin-top: 0;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.panel-header h3 {
margin-bottom: 0;
}
.comments-empty, .annotations-empty {
text-align: center;
padding: 2rem;
color: #718096;
}
.comments-loading, .annotations-loading {
text-align: center;
padding: 1.5rem;
color: #718096;
}
.comments-error {
text-align: center;
padding: 1rem;
color: #991B1B;
}
/* Individual comment */
.comment-item {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid #4299e1;
}
.dark .comment-item {
background-color: #2d3748;
border-left-color: #63b3ed;
}
.comment-item.comment-reply {
margin-left: 1.5rem;
border-left-color: #a0aec0;
background-color: #edf2f7;
}
.dark .comment-item.comment-reply {
background-color: #1a202c;
border-left-color: #4a5568;
}
.comment-item.comment-resolved {
opacity: 0.75;
border-left-color: #48bb78;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-author {
font-weight: 600;
color: #2d3748;
font-size: 0.875rem;
}
.dark .comment-author {
color: #e2e8f0;
}
.comment-time {
font-size: 0.75rem;
color: #718096;
}
.comment-resolved-badge {
font-size: 0.75rem;
color: #065F46;
background-color: #D1FAE5;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-weight: 600;
}
.dark .comment-resolved-badge {
background-color: #065F46;
color: #D1FAE5;
}
.comment-body {
color: #4a5568;
font-size: 0.9375rem;
line-height: 1.5;
margin-bottom: 0.5rem;
word-break: break-word;
}
.dark .comment-body {
color: #cbd5e0;
}
.comment-mention {
color: #3182ce;
font-weight: 600;
}
.dark .comment-mention {
color: #63b3ed;
}
.comment-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-action-btn {
background: none;
border: 1px solid #e2e8f0;
color: #718096;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-height: 28px;
}
.comment-action-btn:hover {
background-color: #edf2f7;
color: #2d3748;
}
.dark .comment-action-btn {
border-color: #4a5568;
color: #a0aec0;
}
.dark .comment-action-btn:hover {
background-color: #4a5568;
color: #e2e8f0;
}
.comment-action-btn--danger:hover {
background-color: #FEE2E2;
color: #991B1B;
border-color: #f56565;
}
.dark .comment-action-btn--danger:hover {
background-color: #742a2a;
color: #feb2b2;
border-color: #f56565;
}
.comment-replies {
margin-top: 0.75rem;
}
/* Comment form */
.comment-form-wrapper {
margin-top: 1rem;
}
.comment-textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.dark .comment-textarea {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.comment-textarea:focus {
outline: none;
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
}
.comment-submit-btn {
background-color: #4299e1;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
min-height: 36px;
}
.comment-submit-btn:hover {
background-color: #3182ce;
}
.comment-cancel-btn {
background-color: #e2e8f0;
color: #4a5568;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
margin-top: 0.5rem;
margin-left: 0.5rem;
min-height: 36px;
}
.dark .comment-cancel-btn {
background-color: #4a5568;
color: #e2e8f0;
}
.comment-edit-btns, .annotation-edit-btns {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.comment-reply-form {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e2e8f0;
}
.dark .comment-reply-form {
border-top-color: #4a5568;
}
/* @mention dropdown */
.mention-dropdown-wrapper {
position: relative;
}
#mention-dropdown {
position: absolute;
bottom: 100%;
left: 0;
width: 280px;
max-height: 200px;
overflow-y: auto;
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 50;
margin-bottom: 0.25rem;
}
.dark #mention-dropdown {
background-color: #2d3748;
border-color: #4a5568;
}
.mention-item {
display: flex;
flex-direction: column;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
background: none;
cursor: pointer;
font-size: 0.875rem;
min-height: 44px;
justify-content: center;
}
.mention-item:hover, .mention-item:focus {
background-color: #edf2f7;
outline: none;
}
.dark .mention-item:hover, .dark .mention-item:focus {
background-color: #4a5568;
}
.mention-user-id {
font-weight: 600;
color: #2d3748;
}
.dark .mention-user-id {
color: #e2e8f0;
}
.mention-display-name {
font-size: 0.75rem;
color: #718096;
}
/* ── Annotations panel ────────────────────────────────────────────────── */
.annotation-item {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
border-left: 3px solid #ecc94b;
}
.dark .annotation-item {
background-color: #2d3748;
border-left-color: #d69e2e;
}
.annotation-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.annotation-type {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
text-transform: capitalize;
}
.annotation-type--note {
background-color: #DBEAFE;
color: #1E3A8A;
}
.annotation-type--highlight {
background-color: #FEF3C7;
color: #92400E;
}
.annotation-type--underline {
background-color: #D1FAE5;
color: #065F46;
}
.annotation-type--strikethrough {
background-color: #FEE2E2;
color: #991B1B;
}
.dark .annotation-type--note {
background-color: #1E3A8A;
color: #DBEAFE;
}
.dark .annotation-type--highlight {
background-color: #92400E;
color: #FEF3C7;
}
.dark .annotation-type--underline {
background-color: #065F46;
color: #D1FAE5;
}
.dark .annotation-type--strikethrough {
background-color: #991B1B;
color: #FEE2E2;
}
.annotation-color-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.annotation-page {
font-size: 0.75rem;
color: #718096;
}
.annotation-page--link {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.75rem;
color: #718096;
text-decoration: none;
min-height: 0;
}
.annotation-page--link:hover {
color: #3182ce;
text-decoration: underline;
}
.dark .annotation-page--link:hover {
color: #63b3ed;
}
.annotation-content {
color: #4a5568;
font-size: 0.9375rem;
line-height: 1.5;
margin-bottom: 0.5rem;
word-break: break-word;
}
.dark .annotation-content {
color: #cbd5e0;
}
.annotation-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #718096;
margin-bottom: 0.5rem;
}
.annotation-author {
font-weight: 600;
}
.annotation-actions {
display: flex;
gap: 0.5rem;
}
.annotation-action-btn {
background: none;
border: 1px solid #e2e8f0;
color: #718096;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
min-height: 28px;
}
.annotation-action-btn:hover {
background-color: #edf2f7;
color: #2d3748;
}
.dark .annotation-action-btn {
border-color: #4a5568;
color: #a0aec0;
}
.dark .annotation-action-btn:hover {
background-color: #4a5568;
color: #e2e8f0;
}
.annotation-action-btn--danger:hover {
background-color: #FEE2E2;
color: #991B1B;
border-color: #f56565;
}
.dark .annotation-action-btn--danger:hover {
background-color: #742a2a;
color: #feb2b2;
}
.annotation-textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.dark .annotation-textarea {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.annotation-textarea:focus {
outline: none;
border-color: #ecc94b;
box-shadow: 0 0 0 2px rgba(236, 201, 75, 0.3);
}
.annotation-select {
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.875rem;
margin-top: 0.5rem;
width: 100%;
}
.dark .annotation-select {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.annotation-submit-btn {
background-color: #ecc94b;
color: #744210;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
min-height: 36px;
}
.annotation-submit-btn:hover {
background-color: #d69e2e;
}
.annotation-cancel-btn {
background-color: #e2e8f0;
color: #4a5568;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
margin-top: 0.5rem;
margin-left: 0.5rem;
min-height: 36px;
}
.dark .annotation-cancel-btn {
background-color: #4a5568;
color: #e2e8f0;
}
/* Annotation form layout */
.annotation-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-top: 0.75rem;
}
.annotation-form-grid .form-group {
display: flex;
flex-direction: column;
}
.annotation-form-grid label {
font-size: 0.75rem;
font-weight: 600;
color: #4a5568;
margin-bottom: 0.25rem;
}
.dark .annotation-form-grid label {
color: #a0aec0;
}
.annotation-form-grid input,
.annotation-form-grid select {
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
padding: 0.5rem;
font-size: 0.875rem;
}
.dark .annotation-form-grid input,
.dark .annotation-form-grid select {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
/* ── Collaboration panels grid ────────────────────────────────────────── */
.collab-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.collab-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="annotations-container">
{% if error %}
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
{% elif file %}
<!-- ── Back + header ── -->
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;" aria-label="Back to File Summary">
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File Summary
</a>
</div>
<div class="annotations-header">
<div>
<div class="annotations-title">
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;margin-right:0.4rem;"></i>
Comments &amp; Annotations
</div>
<div class="annotations-subtitle">{{ file.original_filename }}</div>
{% if multi_user_enabled %}
<div class="annotations-subtitle" style="margin-top:0.25rem;">
<i class="fas fa-user" aria-hidden="true" style="margin-right:0.25rem;"></i>
{{ _("file.owner_label") }}: <strong>{{ owner_display or _("file.owner_unowned") }}</strong>
{% if file.owner_id is none %}
&mdash;
<button
id="claim-btn"
aria-label="{{ _('file.claim_ownership') }}"
style="background:#10b981;color:#fff;border:none;border-radius:0.375rem;padding:0.25rem 0.75rem;font-size:0.8rem;font-weight:600;cursor:pointer;"
>
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
</button>
<span id="claim-msg" style="font-size:0.8rem;margin-left:0.5rem;display:none;" role="alert"></span>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- ── PDF Viewer (EmbedPDF) ───────────────────────────────────────────── -->
{% if is_pdf and (processed_file_exists or original_file_exists) %}
<div class="pdf-viewer-card">
<div class="pdf-viewer-card-header">
<i class="fas fa-file-pdf" aria-hidden="true" style="color:#ef4444;"></i>
Document Viewer
</div>
<div id="embedpdf-viewer" aria-label="PDF document viewer"></div>
</div>
{% endif %}
<!-- ── Comments & Annotations ──────────────────────────────────────────── -->
<div class="collab-card">
<div class="collab-grid">
<!-- Comments Panel -->
<section class="comments-panel" aria-label="{{ _('comments.heading') }}">
<div class="panel-header">
<h3><i class="fas fa-comments" aria-hidden="true"></i> {{ _("comments.heading") }}</h3>
</div>
<div id="comments-list" aria-live="polite"></div>
<!-- New comment form -->
<div class="comment-form-wrapper">
<form id="comment-form" aria-label="{{ _('comments.add_comment') }}">
<div class="mention-dropdown-wrapper">
<div id="mention-dropdown" class="hidden" role="listbox" aria-label="{{ _('comments.mention_users') }}"></div>
<textarea
id="comment-input"
class="comment-textarea"
placeholder="{{ _('comments.body_placeholder') }}"
rows="3"
aria-label="{{ _('comments.body_placeholder') }}"
maxlength="10000"
></textarea>
</div>
<button type="submit" class="comment-submit-btn">
<i class="fas fa-paper-plane" aria-hidden="true"></i> {{ _("comments.add_comment") }}
</button>
</form>
</div>
</section>
<!-- Annotations Panel -->
<section class="annotations-panel" aria-label="{{ _('annotations.heading') }}">
<div class="panel-header">
<h3><i class="fas fa-sticky-note" aria-hidden="true"></i> {{ _("annotations.heading") }}</h3>
</div>
<div id="annotations-list" aria-live="polite"></div>
<!-- New annotation form -->
<div class="comment-form-wrapper">
<form id="annotation-form" aria-label="{{ _('annotations.add') }}">
<textarea
id="annotation-content-input"
class="annotation-textarea"
placeholder="{{ _('annotations.content_placeholder') }}"
rows="2"
aria-label="{{ _('annotations.content_placeholder') }}"
maxlength="5000"
></textarea>
<div class="annotation-form-grid">
<div class="form-group">
<label for="annotation-page-input">{{ _("annotations.page") }}</label>
<input type="number" id="annotation-page-input" min="1" value="1" aria-label="{{ _('annotations.page') }}">
</div>
<div class="form-group">
<label for="annotation-type-input">{{ _("annotations.type") }}</label>
<select id="annotation-type-input" aria-label="Annotation type">
<option value="note">{{ _("annotations.type_note") }}</option>
<option value="highlight">{{ _("annotations.type_highlight") }}</option>
<option value="underline">{{ _("annotations.type_underline") }}</option>
<option value="strikethrough">{{ _("annotations.type_strikethrough") }}</option>
</select>
</div>
<div class="form-group">
<label for="annotation-color-input">{{ _("annotations.color") }}</label>
<input type="color" id="annotation-color-input" value="#ffff00" aria-label="{{ _('annotations.color') }}">
</div>
</div>
<button type="submit" class="annotation-submit-btn">
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("annotations.add") }}
</button>
</form>
</div>
</section>
</div>
</div>
<!-- ── Sharing & Permissions ──────────────────────────────────────────── -->
{% if current_user_role == 'owner' %}
<div class="collab-card" id="sharing-panel" style="margin-top:1.5rem;">
<div class="panel-header" style="padding:1rem 1.25rem;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;gap:0.5rem;">
<h3 style="font-size:1rem;font-weight:600;color:#1e293b;margin:0;">
<i class="fas fa-share-alt" aria-hidden="true" style="color:#3b82f6;"></i>
{{ _("sharing.heading") }}
</h3>
</div>
<div style="padding:1rem 1.25rem;">
<!-- Current shares list -->
<div id="sharing-list" aria-live="polite" style="margin-bottom:1rem;">
<p style="color:#64748b;font-size:0.875rem;">{{ _("sharing.loading") }}</p>
</div>
<!-- Add share form -->
<form id="sharing-form" aria-label="{{ _('sharing.add_share') }}" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<div style="flex:1;min-width:180px;">
<label for="share-user-input" style="display:block;font-size:0.8rem;color:#475569;margin-bottom:0.25rem;">
{{ _("sharing.user_id_label") }}
</label>
<input
type="text"
id="share-user-input"
placeholder="{{ _('sharing.user_id_placeholder') }}"
style="width:100%;padding:0.5rem 0.75rem;border:1px solid #cbd5e1;border-radius:0.375rem;font-size:0.875rem;"
/>
</div>
<div style="min-width:120px;">
<label for="share-role-input" style="display:block;font-size:0.8rem;color:#475569;margin-bottom:0.25rem;">
{{ _("sharing.role_label") }}
</label>
<select
id="share-role-input"
style="width:100%;padding:0.5rem 0.75rem;border:1px solid #cbd5e1;border-radius:0.375rem;font-size:0.875rem;background:#fff;"
>
<option value="viewer">{{ _("sharing.role_viewer") }}</option>
<option value="editor">{{ _("sharing.role_editor") }}</option>
</select>
</div>
<button type="submit" style="padding:0.5rem 1rem;background:#3b82f6;color:#fff;border:none;border-radius:0.375rem;font-size:0.875rem;font-weight:600;cursor:pointer;min-height:2.25rem;" aria-label="{{ _('sharing.add_share') }}">
<i class="fas fa-user-plus" aria-hidden="true"></i> {{ _("sharing.add_share") }}
</button>
</form>
<p id="sharing-form-error" style="color:#ef4444;font-size:0.8rem;margin-top:0.5rem;display:none;" role="alert"></p>
</div>
</div>
{% endif %}
{% else %}
<!-- file is None without error -->
<div class="error-box">Document not found.</div>
{% endif %}
</div>
<!-- Comments & Annotations JS -->
<script src="{{ url_for('static', path='js/comments.js') }}" defer></script>
<script src="{{ url_for('static', path='js/annotations.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
{% if file %}
var fileId = {{ file.id | tojson }};
// Detect current user from whoami endpoint
fetch('/api/auth/whoami')
.then(function (r) { return r.json(); })
.then(function (data) {
var userId = (data && (data.email || data.preferred_username)) || '';
var commentsI18n = {
empty: {{ _("comments.empty") | tojson }},
add_comment: {{ _("comments.add_comment") | tojson }},
add_reply: {{ _("comments.add_reply") | tojson }},
edit: {{ _("comments.edit") | tojson }},
save: {{ _("comments.save") | tojson }},
resolve: {{ _("comments.resolve") | tojson }},
resolved: {{ _("comments.resolved") | tojson }},
unresolve: {{ _("comments.unresolve") | tojson }},
delete_confirm: {{ _("comments.delete_confirm") | tojson }},
reply_placeholder: {{ _("comments.reply_placeholder") | tojson }},
body_placeholder: {{ _("comments.body_placeholder") | tojson }},
mention_users: {{ _("comments.mention_users") | tojson }},
cancel: {{ _("common.cancel") | tojson }}
};
var annotationsI18n = {
empty: {{ _("annotations.empty") | tojson }},
add: {{ _("annotations.add") | tojson }},
save: {{ _("annotations.save") | tojson }},
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
page: {{ _("annotations.page") | tojson }},
color: {{ _("annotations.color") | tojson }},
go_to_page: {{ _("annotations.go_to_page") | tojson }},
type_note: {{ _("annotations.type_note") | tojson }},
type_highlight: {{ _("annotations.type_highlight") | tojson }},
type_underline: {{ _("annotations.type_underline") | tojson }},
type_strikethrough: {{ _("annotations.type_strikethrough") | tojson }},
cancel: {{ _("common.cancel") | tojson }}
};
if (typeof initComments === 'function') {
initComments(fileId, userId, commentsI18n);
}
if (typeof initAnnotations === 'function') {
initAnnotations(fileId, userId, annotationsI18n);
}
{% if current_user_role == 'owner' %}
if (typeof initSharing === 'function') {
initSharing(fileId, {
heading: {{ _("sharing.heading") | tojson }},
loading: {{ _("sharing.loading") | tojson }},
no_shares: {{ _("sharing.no_shares") | tojson }},
add_share: {{ _("sharing.add_share") | tojson }},
role_viewer: {{ _("sharing.role_viewer") | tojson }},
role_editor: {{ _("sharing.role_editor") | tojson }},
revoke: {{ _("sharing.revoke") | tojson }},
revoke_confirm: {{ _("sharing.revoke_confirm") | tojson }},
user_id_label: {{ _("sharing.user_id_label") | tojson }},
user_id_placeholder: {{ _("sharing.user_id_placeholder") | tojson }},
role_label: {{ _("sharing.role_label") | tojson }},
error_empty_user: {{ _("sharing.error_empty_user") | tojson }},
change_role: {{ _("sharing.change_role") | tojson }},
});
}
{% endif %}
})
.catch(function () {
// Auth disabled — initialise with empty user
if (typeof initComments === 'function') initComments(fileId, '', {});
if (typeof initAnnotations === 'function') initAnnotations(fileId, '', {});
});
{% endif %}
});
</script>
<!-- Sharing JS -->
{% if current_user_role == 'owner' %}
<script src="{{ url_for('static', path='js/sharing.js') }}" defer></script>
{% endif %}
<!-- ── EmbedPDF Viewer init ── -->
{% if file and is_pdf and (processed_file_exists or original_file_exists) %}
<script async type="module">
import EmbedPDF from 'https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js';
const viewerEl = document.getElementById('embedpdf-viewer');
if (viewerEl) {
const fileId = {{ file.id | tojson }};
{% if processed_file_exists %}
const pdfUrl = '/api/files/' + fileId + '/preview?version=processed';
{% else %}
const pdfUrl = '/api/files/' + fileId + '/preview?version=original';
{% endif %}
const viewer = EmbedPDF.init({
type: 'container',
target: viewerEl,
src: pdfUrl,
});
if (viewer) {
viewer.registry.then(function (registry) {
// ── Page sync: viewer page change → update annotation form ──────────
var scrollPlugin = registry.getPlugin('scroll');
if (scrollPlugin) {
var scroll = scrollPlugin.provides();
scroll.onPageChange(function (event) {
var pageInput = document.getElementById('annotation-page-input');
if (pageInput) {
pageInput.value = String(event.pageNumber);
}
});
// Expose scrollToPage so the annotations panel can navigate the viewer
window._embedpdfScrollToPage = function (pageNumber) {
scroll.scrollToPage({ pageNumber: pageNumber });
};
}
// ── Auto-save: viewer annotation events → DocuElevate API ───────────
var annotationPlugin = registry.getPlugin('annotation');
if (annotationPlugin) {
var annotation = annotationPlugin.provides();
annotation.onAnnotationEvent(function (event) {
if (event.type !== 'create') return;
var ann = event.annotation;
var pageIndex = typeof ann.pageIndex === 'number' ? ann.pageIndex
: (typeof event.pageIndex === 'number' ? event.pageIndex : 0);
var page = pageIndex + 1;
var rect = ann.rect || { x: 0, y: 0, width: 0, height: 0 };
var color = ann.strokeColor || ann.color || undefined;
var content = (ann.contents || '').trim();
// Map PDF annotation subtypes to DocuElevate annotation types
var typeMap = {
highlight: 'highlight',
underline: 'underline',
strikeout: 'strikethrough',
squiggly: 'underline',
text: 'note',
freetext: 'note',
ink: 'note',
square: 'note',
circle: 'note',
};
var annType = typeMap[String(ann.type).toLowerCase()] || 'note';
if (!content) {
var typeLabel = annType.charAt(0).toUpperCase() + annType.slice(1);
content = typeLabel + ' \u2014 p.' + page;
}
var payload = {
page: page,
x: rect.x || 0,
y: rect.y || 0,
width: rect.width || 0,
height: rect.height || 0,
annotation_type: annType,
content: content,
};
if (color) {
payload.color = color;
}
fetch('/api/files/' + fileId + '/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) {
if (r.ok && typeof window._reloadAnnotations === 'function') {
window._reloadAnnotations();
}
})
.catch(function (err) {
console.error('Failed to save viewer annotation:', err);
});
});
}
}).catch(function () {});
}
}
</script>
{% endif %}
{% if multi_user_enabled and file and file.owner_id is none %}
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
initClaimOwnership({{ file.id | tojson }}, {
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
success: {{ _("file.claim_ownership_success") | tojson }},
failed: {{ _("file.claim_ownership_failed") | tojson }}
});
});
</script>
{% endif %}
{% endblock %}
+226
View File
@@ -0,0 +1,226 @@
{% extends "base.html" %}
{% block title %}{{ file.document_title or file.original_filename or 'Document' }} - DocuElevate{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
<style>
.summary-container { max-width: 900px; margin: 0 auto; }
/* ── header ── */
.summary-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
}
.summary-title { font-size: 1.75rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
.summary-subtitle { font-size: 0.9rem; color: #6b7280; margin-top: 0.25rem; font-family: monospace; }
.dark .summary-title { color: #f3f4f6; }
.dark .summary-subtitle { color: #9ca3af; }
/* ── status pill ── */
.status-pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.35rem 0.85rem; border-radius: 9999px;
font-size: 0.8rem; font-weight: 600; white-space: nowrap;
}
.pill-completed { background: #d1fae5; color: #065f46; }
.pill-failed { background: #fee2e2; color: #991b1b; }
.pill-processing { background: #dbeafe; color: #1e3a8a; }
.pill-pending { background: #fef3c7; color: #92400e; }
.pill-duplicate { background: #e5e7eb; color: #374151; }
/* ── cards ── */
.summary-card {
background: white; border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
padding: 1.5rem; margin-bottom: 1.25rem;
}
.dark .summary-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.summary-card-title {
font-size: 1rem; font-weight: 700; color: #374151;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.dark .summary-card-title { color: #d1d5db; }
/* ── info rows ── */
.info-row { display: flex; justify-content: space-between; padding: 0.4rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.875rem; }
.info-row:last-child { border-bottom: none; }
.info-key { color: #6b7280; }
.info-val { color: #1f2937; font-family: monospace; text-align: right; word-break: break-all; max-width: 60%; }
.dark .info-row { border-bottom-color: #374151; }
.dark .info-key { color: #9ca3af; }
.dark .info-val { color: #e5e7eb; }
/* ── nav cards ── */
.nav-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.25rem; }
@media (max-width: 768px) { .nav-grid { grid-template-columns: 1fr; } }
.nav-card {
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,.08);
padding: 1.5rem; text-decoration: none; color: #374151;
transition: box-shadow 0.15s, transform 0.15s; min-height: 120px;
}
.nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.15); transform: translateY(-2px); }
.dark .nav-card { background: #1f2937; color: #e5e7eb; }
.dark .nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.4); }
.nav-card i { font-size: 1.5rem; margin-bottom: 0.75rem; }
.nav-card-title { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.25rem; }
.nav-card-desc { font-size: 0.8rem; color: #6b7280; text-align: center; }
.dark .nav-card-desc { color: #9ca3af; }
/* ── action buttons ── */
.action-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 1.1rem; border-radius: 0.375rem;
font-size: 0.875rem; font-weight: 600; text-decoration: none;
cursor: pointer; border: none; transition: filter 0.1s;
}
.action-btn:hover { filter: brightness(0.92); }
.btn-primary { background: #3b82f6; color: white; }
.btn-secondary { background: #f3f4f6; color: #374151; }
/* ── error ── */
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
</style>
{% endblock %}
{% block content %}
<div class="summary-container">
{% if error %}
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
{% elif file %}
<!-- ── Back + header ── -->
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files">
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files
</a>
<div class="summary-header">
<div>
<div class="summary-title">
{% if gpt_metadata and gpt_metadata.filename %}
{{ gpt_metadata.filename | replace('.pdf','') | replace('_',' ') }}
{% elif file.document_title %}
{{ file.document_title }}
{% else %}
{{ file.original_filename or '(untitled)' }}
{% endif %}
</div>
<div class="summary-subtitle">{{ file.original_filename }}</div>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<!-- Status pill -->
{% if file.is_duplicate %}
<span class="status-pill pill-duplicate"><i class="fas fa-copy" aria-hidden="true"></i> Duplicate</span>
{% elif step_summary %}
{% set main_completed = step_summary.main.success + step_summary.main.skipped %}
{% if step_summary.main.failure > 0 or step_summary.uploads.failure > 0 %}
<span class="status-pill pill-failed"><i class="fas fa-times-circle" aria-hidden="true"></i> Failed</span>
{% elif step_summary.main["in_progress"] > 0 or step_summary.uploads["in_progress"] > 0 %}
<span class="status-pill pill-processing"><i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> Processing</span>
{% elif step_summary.total_main_steps > 0 and main_completed == step_summary.total_main_steps and step_summary.main.failure == 0 %}
<span class="status-pill pill-completed"><i class="fas fa-check-circle" aria-hidden="true"></i> Completed</span>
{% else %}
<span class="status-pill pill-pending"><i class="fas fa-pause-circle" aria-hidden="true"></i> Pending</span>
{% endif %}
{% endif %}
</div>
</div>
<!-- ── Navigation cards ── -->
<nav class="nav-grid" aria-label="File sections">
<a href="/files/{{ file.id }}/detail" class="nav-card">
<i class="fas fa-file-alt" aria-hidden="true" style="color:#3b82f6;"></i>
<div class="nav-card-title">Document Detail</div>
<div class="nav-card-desc">Metadata, preview &amp; extracted text</div>
</a>
<a href="/files/{{ file.id }}/process" class="nav-card">
<i class="fas fa-cogs" aria-hidden="true" style="color:#6366f1;"></i>
<div class="nav-card-title">Processing</div>
<div class="nav-card-desc">Pipeline status &amp; processing history</div>
</a>
<a href="/files/{{ file.id }}/annotations" class="nav-card">
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;"></i>
<div class="nav-card-title">Comments &amp; Annotations</div>
<div class="nav-card-desc">Discussion &amp; document annotations</div>
</a>
</nav>
<!-- ── File info card ── -->
<div class="summary-card">
<div class="summary-card-title"><i class="fas fa-info-circle" aria-hidden="true" style="color:#6366f1;margin-right:0.4rem;"></i>File Information</div>
<div class="info-row"><span class="info-key">File ID</span><span class="info-val">{{ file.id }}</span></div>
<div class="info-row"><span class="info-key">Filename</span><span class="info-val">{{ file.original_filename }}</span></div>
<div class="info-row"><span class="info-key">Size</span><span class="info-val">{{ (file.file_size / 1024) | round(1) }} KB</span></div>
<div class="info-row"><span class="info-key">MIME Type</span><span class="info-val">{{ file.mime_type or 'unknown' }}</span></div>
<div class="info-row"><span class="info-key">Created</span><span class="info-val">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}</span></div>
{% if file.detected_language %}
<div class="info-row"><span class="info-key">Language</span><span class="info-val">{{ file.detected_language }}</span></div>
{% endif %}
{% if pipeline_info %}
<div class="info-row"><span class="info-key">Pipeline</span><span class="info-val">{{ pipeline_info.name }}</span></div>
{% endif %}
{% if file.document_title %}
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
{% endif %}
{% if multi_user_enabled %}
<div class="info-row">
<span class="info-key">{{ _("file.owner_label") }}</span>
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
</div>
{% endif %}
</div>
<!-- ── Quick actions ── -->
<div class="summary-card">
<div class="summary-card-title"><i class="fas fa-bolt" aria-hidden="true" style="color:#f59e0b;margin-right:0.4rem;"></i>Quick Actions</div>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
{% if processed_file_exists %}
<a href="/api/files/{{ file.id }}/download?version=processed" class="action-btn btn-primary">
<i class="fas fa-download" aria-hidden="true"></i> Download Processed
</a>
{% endif %}
{% if original_file_exists %}
<a href="/api/files/{{ file.id }}/download?version=original" class="action-btn btn-secondary">
<i class="fas fa-download" aria-hidden="true"></i> Download Original
</a>
{% endif %}
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
</a>
{% if multi_user_enabled and file.owner_id is none %}
<button
class="action-btn btn-primary"
id="claim-btn"
aria-label="{{ _('file.claim_ownership') }}"
style="background:#10b981;"
>
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
</button>
{% endif %}
</div>
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
</div>
{% else %}
<!-- file is None without error -->
<div class="error-box">Document not found.</div>
{% endif %}
</div>
{% if multi_user_enabled and file and file.owner_id is none %}
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
initClaimOwnership({{ file.id | tojson }}, {
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
success: {{ _("file.claim_ownership_success") | tojson }},
failed: {{ _("file.claim_ownership_failed") | tojson }}
});
});
</script>
{% endif %}
{% endblock %}
+31 -4
View File
@@ -159,8 +159,8 @@
{% elif file %}
<!-- ── Back + header ── -->
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files">
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files
<a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to File Summary">
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File Summary
</a>
<div class="doc-header">
@@ -195,8 +195,8 @@
{% endif %}
<!-- Process detail link -->
<a href="/files/{{ file.id }}/detail" class="action-btn btn-process">
<i class="fas fa-cogs" aria-hidden="true"></i> Processing Details
<a href="/files/{{ file.id }}/process" class="action-btn btn-process">
<i class="fas fa-cogs" aria-hidden="true"></i> Processing
</a>
</div>
</div>
@@ -316,6 +316,12 @@
{% endif %}
</span>
</div>
{% if multi_user_enabled %}
<div class="info-row">
<span class="info-key">{{ _("file.owner_label") }}</span>
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
</div>
{% endif %}
</div>
<!-- Actions -->
@@ -344,7 +350,18 @@
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
</a>
{% if multi_user_enabled and file.owner_id is none %}
<button
class="action-btn btn-primary"
id="claim-btn"
aria-label="{{ _('file.claim_ownership') }}"
style="background:#10b981;border:none;cursor:pointer;"
>
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
</button>
{% endif %}
</div>
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
</div>
</div>
@@ -937,6 +954,16 @@
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
{% endif %}
{% endif %}
{% if multi_user_enabled and file and file.owner_id is none %}
initClaimOwnership({{ file.id | tojson }}, {
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
success: {{ _("file.claim_ownership_success") | tojson }},
failed: {{ _("file.claim_ownership_failed") | tojson }}
});
{% endif %}
});
</script>
{% if multi_user_enabled and file and file.owner_id is none %}
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
{% endif %}
{% endblock %}
+1 -1
View File
@@ -904,7 +904,7 @@
function viewFileDetail(fileId, event) {
if (event) event.stopPropagation();
window.location.href = `/files/${fileId}/detail`;
window.location.href = `/files/${fileId}`;
}
// ── Preview modal ──
+42 -4
View File
@@ -166,15 +166,32 @@
<h3 class="text-xl font-medium mb-4">Step 5: Complete OAuth Setup</h3>
<p class="mb-4">Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.</p>
<div class="space-y-4">
{% if user_mode and has_system_credentials %}
<!-- System credentials toggle (user mode only) -->
<div class="bg-green-50 border border-green-200 rounded-md p-4">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
<div>
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your Google account without needing your own Google Cloud app registration.</p>
</div>
</label>
</div>
{% endif %}
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
<div class="space-y-4">
<div>
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
<input type="text" id="client-id" 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 OAuth Client ID" value="{{ client_id_value }}">
<input type="text" id="client-id" 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 OAuth Client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
</div>
<div>
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
<input type="password" id="client-secret" 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 OAuth Client Secret" value="{{ client_secret_value }}">
<input type="password" id="client-secret" 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 OAuth Client Secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
</div>
</div>
</div>
<div>
@@ -430,6 +447,9 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const userMode = {{ 'true' if user_mode else 'false' }};
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
const systemClientId = {{ (client_id_value or '') | tojson }};
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
// Store integration_id if provided (for per-user OAuth flow)
const integrationId = "{{ integration_id or '' }}";
@@ -437,6 +457,19 @@ document.addEventListener('DOMContentLoaded', function() {
sessionStorage.setItem('oauth_integration_id', integrationId);
}
// System credentials toggle
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
const customCredsSection = document.getElementById('custom-creds-section');
if (useSystemCredsCheckbox && customCredsSection) {
useSystemCredsCheckbox.addEventListener('change', function() {
if (this.checked) {
customCredsSection.classList.add('hidden');
} else {
customCredsSection.classList.remove('hidden');
}
});
}
// Form elements
const clientIdInput = document.getElementById('client-id');
const clientSecretInput = document.getElementById('client-secret');
@@ -719,8 +752,10 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
// Start OAuth flow button
if (startOauthFlowBtn) {
startOauthFlowBtn.addEventListener('click', function() {
const clientId = clientIdInput.value.trim();
const clientSecret = clientSecretInput.value.trim();
// Determine which credentials to use
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
const clientId = useSystemCreds ? systemClientId : clientIdInput.value.trim();
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
const folderId = folderIdInput ? folderIdInput.value.trim() : '';
if (!clientId) {
@@ -736,6 +771,9 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
// Save values to session storage for use after redirect
sessionStorage.setItem('google_drive_client_id', clientId);
sessionStorage.setItem('google_drive_client_secret', clientSecret);
if (useSystemCreds) {
sessionStorage.setItem('google_drive_use_system_creds', 'true');
}
// In admin mode store folder_id; in user mode the config is already set
if (!userMode && folderId) {
+44 -5
View File
@@ -129,15 +129,30 @@
{% if user_mode %}OAuth Wizard{% else %}Complete Setup with Wizard{% endif %}
</h2>
<div class="space-y-4">
{% if user_mode and has_system_credentials %}
<!-- System credentials toggle (user mode only) -->
<div class="bg-green-50 border border-green-200 rounded-md p-4">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
<div>
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your OneDrive account without needing your own Azure app registration.</p>
</div>
</label>
</div>
{% endif %}
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
<div class="space-y-4">
<div>
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
<input type="text" id="client-id" 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 Azure AD application client ID" value="{{ client_id_value }}">
<input type="text" id="client-id" 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 Azure AD application client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
</div>
<div>
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
<input type="password" id="client-secret" 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 Azure AD application client secret" value="{{ client_secret_value }}">
<input type="password" id="client-secret" 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 Azure AD application client secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
</div>
<div>
@@ -145,6 +160,8 @@
<input type="text" id="tenant-id" 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="common" value="{{ tenant_id }}">
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
</div>
</div>
</div>
{% if not user_mode %}
<div>
@@ -289,6 +306,10 @@ ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code>
<script>
document.addEventListener('DOMContentLoaded', function() {
const userMode = {{ 'true' if user_mode else 'false' }};
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
const systemClientId = {{ (client_id_value or '') | tojson }};
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
const systemTenantId = {{ (tenant_id or 'common') | tojson }};
// Store integration_id if provided (for per-user OAuth flow)
const integrationId = "{{ integration_id or '' }}";
@@ -302,6 +323,19 @@ document.addEventListener('DOMContentLoaded', function() {
const refreshTokenBtn = document.getElementById('refresh-token-btn');
const tokenStatus = document.getElementById('token-status');
const clientSecretInput = document.getElementById('client-secret');
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
const customCredsSection = document.getElementById('custom-creds-section');
// Toggle custom credentials section visibility
if (useSystemCredsCheckbox && customCredsSection) {
useSystemCredsCheckbox.addEventListener('change', function() {
if (this.checked) {
customCredsSection.classList.add('hidden');
} else {
customCredsSection.classList.remove('hidden');
}
});
}
// Modal elements
const resultModal = document.getElementById('resultModal');
@@ -351,10 +385,12 @@ document.addEventListener('DOMContentLoaded', function() {
// Start Authentication Flow button click
startAuthFlowBtn.addEventListener('click', function() {
const clientId = document.getElementById('client-id').value.trim();
const clientSecret = clientSecretInput.value.trim();
// Determine which credentials to use
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
const clientId = useSystemCreds ? systemClientId : document.getElementById('client-id').value.trim();
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
const redirectUri = window.location.origin + "/onedrive-callback";
const tenantId = document.getElementById('tenant-id').value.trim() || 'common';
const tenantId = useSystemCreds ? systemTenantId : (document.getElementById('tenant-id').value.trim() || 'common');
if (!clientId) {
showModal('error', 'Validation Error', 'Please enter your Client ID');
@@ -370,6 +406,9 @@ document.addEventListener('DOMContentLoaded', function() {
sessionStorage.setItem('onedrive_client_id', clientId);
sessionStorage.setItem('onedrive_client_secret', clientSecret);
sessionStorage.setItem('onedrive_tenant_id', tenantId);
if (useSystemCreds) {
sessionStorage.setItem('onedrive_use_system_creds', 'true');
}
// In admin mode also store folder path; in user mode the config is already set
if (!userMode) {
+157 -6
View File
@@ -59,6 +59,40 @@
</div>
</div>
<!-- Folder selector (shown after authorization) -->
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
<h3 class="font-medium text-lg mb-3">
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
</svg>
Select Folder
</h3>
<p class="text-sm text-gray-600 mb-3">Browse your OneDrive to select a folder for this integration.</p>
<div class="mb-3">
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
</div>
</div>
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
<div class="p-4 text-center text-gray-500">
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
<p class="text-sm">Loading folders…</p>
</div>
</div>
<div class="mt-3 flex items-center gap-3">
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save Folder
</button>
</div>
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
</div>
<div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3">
@@ -177,19 +211,22 @@ document.addEventListener('DOMContentLoaded', function() {
}
return response.json();
}).then(() => {
// Clean up
// Clean up session storage
sessionStorage.removeItem('onedrive_client_id');
sessionStorage.removeItem('onedrive_client_secret');
sessionStorage.removeItem('onedrive_tenant_id');
sessionStorage.removeItem('onedrive_folder_path');
sessionStorage.removeItem('oauth_integration_id');
sessionStorage.removeItem('onedrive_use_system_creds');
// Show brief success then redirect to integrations
document.getElementById('processing-message').innerHTML =
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>' +
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
// Hide processing spinner, show success
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
document.getElementById('processing-message').innerHTML =
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>';
document.getElementById('success-container').classList.remove('hidden');
// Show folder browser with the access token
initFolderBrowser(data.access_token, integrationId);
});
}
@@ -291,6 +328,120 @@ ONEDRIVE_FOLDER_PATH=${folderPath || 'Documents/Uploads'}`;
});
}
}
// ── Folder browser ────────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function initFolderBrowser(accessToken, integrationId) {
const folderSelector = document.getElementById('folder-selector');
if (!folderSelector || !integrationId || !accessToken) return;
folderSelector.classList.remove('hidden');
const folderList = document.getElementById('folder-list');
const breadcrumb = document.getElementById('folder-breadcrumb');
const selectedInput = document.getElementById('selected-folder-path');
const saveBtn = document.getElementById('save-folder-btn');
const saveStatus = document.getElementById('folder-save-status');
function loadFolders(path) {
folderList.innerHTML = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
const formData = new FormData();
formData.append('access_token', accessToken);
formData.append('path', path);
fetch('/api/onedrive/list-folders', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.folders && data.folders.length > 0) {
folderList.innerHTML = data.folders.map(f =>
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
`<svg class="h-5 w-5 text-blue-400 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="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
`</button>`
).join('');
folderList.querySelectorAll('.folder-item').forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.getAttribute('data-path');
selectedInput.value = p;
loadFolders(p.replace(/^\//, ''));
});
});
} else {
folderList.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
}
updateBreadcrumb(path);
})
.catch(err => {
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
});
}
function updateBreadcrumb(path) {
const parts = path.split('/').filter(Boolean);
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
let accumulated = '';
for (const part of parts) {
accumulated += '/' + part;
html += `<span class="mx-1 text-gray-400">/</span>`;
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated.replace(/^\//, ''))}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
}
breadcrumb.innerHTML = html;
breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.getAttribute('data-path');
selectedInput.value = p ? '/' + p : '/';
loadFolders(p);
});
});
}
// Save selected folder to integration config
saveBtn.addEventListener('click', () => {
const folderPath = selectedInput.value.trim() || '/';
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
fetch(`/api/integrations/${integrationId}`)
.then(r => r.json())
.then(intg => {
const cfg = intg.config || {};
cfg.folder_path = folderPath;
return fetch(`/api/integrations/${integrationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: cfg }),
});
})
.then(r => {
if (!r.ok) throw new Error('Failed to save folder');
return r.json();
})
.then(() => {
saveStatus.textContent = '✓ Folder saved! Redirecting…';
saveStatus.className = 'mt-2 text-sm text-green-600';
saveStatus.classList.remove('hidden');
saveBtn.textContent = 'Saved ✓';
setTimeout(() => { window.location.href = '/integrations'; }, 1500);
})
.catch(err => {
saveStatus.textContent = 'Error: ' + err.message;
saveStatus.className = 'mt-2 text-sm text-red-600';
saveStatus.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Folder';
});
});
// Load root folders initially
loadFolders('');
}
});
</script>
{% endblock %}
+15 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Create Account</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
@@ -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.';
+90 -1
View File
@@ -315,7 +315,23 @@
"admin_users.total_count_users": "{count} users",
"admin_users.total_no_users": "No users",
"admin_users.total_one_user": "1 user",
"annotations.add": "Add annotation",
"annotations.color": "Color",
"annotations.content_placeholder": "Write an annotation...",
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
"annotations.deleted": "Annotation deleted",
"annotations.empty": "No annotations yet",
"annotations.go_to_page": "Go to page",
"annotations.heading": "Annotations",
"annotations.page": "Page",
"annotations.save": "Save",
"annotations.type_highlight": "Highlight",
"annotations.type_note": "Note",
"annotations.type_strikethrough": "Strikethrough",
"annotations.type_underline": "Underline",
"annotations.updated": "Annotation updated",
"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 +343,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 +358,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!",
@@ -471,6 +499,21 @@
"billing.success_heading": "You're all set!",
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
"billing.success_page_title": "DocuElevate - Subscription Activated",
"comments.add_comment": "Add comment",
"comments.add_reply": "Reply",
"comments.body_placeholder": "Write a comment... Use @username to mention someone",
"comments.delete_confirm": "Are you sure you want to delete this comment?",
"comments.deleted": "Comment deleted",
"comments.edit": "Edit",
"comments.empty": "No comments yet",
"comments.heading": "Comments",
"comments.mention_users": "Mention users",
"comments.reply_placeholder": "Write a reply...",
"comments.resolve": "Resolve",
"comments.resolved": "Resolved",
"comments.save": "Save",
"comments.unresolve": "Reopen",
"comments.updated": "Comment updated",
"common.actions": "Actions",
"common.active": "Active",
"common.all": "All",
@@ -575,6 +618,22 @@
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
"cookie_policy.s5_privacy_link": "Privacy Notice",
"cookie_policy.s5_terms_link": "Terms of Service",
"connections.configure": "Configure",
"connections.configured": "Connected",
"connections.description": "Configure external authentication providers like OAuth2 and SAML.",
"connections.frontend_url_note": "Note: Requires Frontend URL to be configured. Configure in System Settings.",
"connections.linked": "Linked",
"connections.mobile_upload_description": "Allow users to upload files from mobile devices by scanning a QR code.",
"connections.mobile_upload_title": "Mobile Phone Upload",
"connections.qr_code_enabled": "Enable QR Code Upload",
"connections.save_note": "Changes to authentication providers require a restart to take effect.",
"connections.sso_auto_login": "Enable SSO Auto Login",
"connections.sso_auto_login_description": "Automatically redirect to SSO login when authentication is required.",
"connections.sso_auto_login_title": "SSO Auto Login",
"connections.title": "Connections",
"connections.unconfigure": "Disconnect",
"connections.unlinked": "Unlinked",
"connections.unlinked_services": "Services",
"credentials.col_action": "Action",
"credentials.col_credential": "Credential",
"credentials.col_source": "Source",
@@ -620,6 +679,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 +696,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",
@@ -1179,6 +1247,7 @@
"nav.api_docs": "API Docs",
"nav.api_tokens": "API Tokens",
"nav.backup_restore": "Backup & Restore",
"nav.connections": "Connections",
"nav.credentials": "Credentials",
"nav.dark_mode": "Dark Mode",
"nav.dashboard": "Dashboard",
@@ -1675,6 +1744,25 @@
"shared.table_aria": "Shared links",
"shared.unlimited_placeholder": "Unlimited",
"shared.your_links": "Your Shared Links",
"sharing.add_share": "Share",
"sharing.change_role": "Change role",
"sharing.error_empty_user": "Please enter a user ID to share with.",
"sharing.heading": "Share with Users",
"sharing.loading": "Loading shares…",
"sharing.no_shares": "Not shared with anyone yet.",
"sharing.revoke": "Revoke access",
"sharing.revoke_confirm": "Remove this user's access to the file?",
"sharing.role_editor": "Editor",
"sharing.role_label": "Role",
"sharing.role_viewer": "Viewer",
"sharing.user_id_label": "User ID or email",
"sharing.user_id_placeholder": "e.g. alice@example.com",
"file.owner_label": "Owner",
"file.owner_unowned": "Unowned",
"file.claim_ownership": "Claim Ownership",
"file.claim_ownership_confirm": "Claim this document as yours? You will become the owner and can manage sharing.",
"file.claim_ownership_success": "You are now the owner of this document.",
"file.claim_ownership_failed": "Could not claim ownership. The document may already have an owner.",
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
"similarity.find_pairs_btn": "Find Pairs",
@@ -1891,5 +1979,6 @@
"upload.uploading": "Uploading...",
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
"upload.url_label": "File URL",
"upload.url_placeholder": "https://example.com/document.pdf"
"upload.url_placeholder": "https://example.com/document.pdf",
"annotations.type": "Type"
}
+3
View File
@@ -24,7 +24,10 @@ from app.models import ( # noqa: F401
ApplicationSettings,
AuditLog,
BackupRecord,
ClassificationRuleModel,
ComplianceTemplate,
DocumentAnnotation,
DocumentComment,
DocumentMetadata,
FileProcessingStep,
FileRecord,
+14
View File
@@ -62,9 +62,14 @@ from app.main import app as fastapi_app # noqa: E402
from app.models import ( # noqa: F401, E402
ApiToken,
AuditLog,
AutomationHook,
ClassificationRuleModel,
ComplianceTemplate,
DocumentAnnotation,
DocumentComment,
DocumentMetadata,
FileRecord,
FileShare,
Pipeline,
PipelineRoutingRule,
PipelineStep,
@@ -115,6 +120,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():
@@ -126,6 +132,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
+340
View File
@@ -181,3 +181,343 @@ class TestFilesAdvancedFiltering:
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 1
# ---------------------------------------------------------------------------
# Saved searches CRUD tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSavedSearchesCRUD:
"""Tests for saved searches CRUD API endpoints."""
def test_list_saved_searches_empty(self, client: TestClient):
"""GET /api/saved-searches returns empty list when no searches exist."""
response = client.get("/api/saved-searches")
assert response.status_code == 200
assert response.json() == []
def test_create_saved_search(self, client: TestClient):
"""POST /api/saved-searches creates a new saved search."""
payload = {
"name": "My Invoices",
"filters": {"tags": "invoice", "status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Invoices"
assert data["filters"]["tags"] == "invoice"
assert data["filters"]["status"] == "completed"
assert "id" in data
def test_create_and_list_saved_search(self, client: TestClient):
"""Creating a saved search makes it appear in the list."""
payload = {
"name": "PDF Files",
"filters": {"mime_type": "application/pdf"},
}
client.post("/api/saved-searches", json=payload)
response = client.get("/api/saved-searches")
assert response.status_code == 200
searches = response.json()
assert len(searches) == 1
assert searches[0]["name"] == "PDF Files"
def test_create_saved_search_missing_name(self, client: TestClient):
"""POST /api/saved-searches without name returns 422."""
payload = {"filters": {"status": "completed"}}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_create_saved_search_empty_filters(self, client: TestClient):
"""POST /api/saved-searches with empty filters returns 422."""
payload = {"name": "Empty", "filters": {}}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_create_saved_search_invalid_filter_keys(self, client: TestClient):
"""POST /api/saved-searches ignores unknown filter keys."""
payload = {
"name": "With unknown keys",
"filters": {"invalid_key": "value", "status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
# Only valid filter key should remain
assert "invalid_key" not in data["filters"]
assert data["filters"]["status"] == "completed"
def test_create_saved_search_only_invalid_keys(self, client: TestClient):
"""POST with only invalid filter keys returns 422."""
payload = {
"name": "All invalid",
"filters": {"bad_key": "value"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_create_duplicate_name(self, client: TestClient):
"""POST /api/saved-searches with duplicate name returns 409."""
payload = {"name": "My Search", "filters": {"status": "completed"}}
response1 = client.post("/api/saved-searches", json=payload)
assert response1.status_code == 201
response2 = client.post("/api/saved-searches", json=payload)
assert response2.status_code == 409
def test_create_saved_search_db_error(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 500 on DB exception."""
# Mock db.add or db.commit to raise an exception
# We can monkeypatch the route's dependency or the models
# It's easier to mock the SavedSearch model's __init__ or db's add
# Since we use db: DbSession, it's an instance of sqlalchemy.orm.Session
from sqlalchemy.orm import Session
original_commit = Session.commit
def mock_commit(*args, **kwargs):
raise Exception("Simulated DB error")
monkeypatch.setattr(Session, "commit", mock_commit)
payload = {
"name": "DB Error Search",
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 500
assert "Failed to save search" in response.json()["detail"]
def test_create_saved_search_limit_reached(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 409 if max limit is reached."""
monkeypatch.setattr("app.api.saved_searches.MAX_SAVED_SEARCHES_PER_USER", 1)
# Create first one
payload1 = {"name": "Search 1", "filters": {"status": "completed"}}
response1 = client.post("/api/saved-searches", json=payload1)
assert response1.status_code == 201
# Creating second one should fail due to limit
payload2 = {"name": "Search 2", "filters": {"status": "pending"}}
response2 = client.post("/api/saved-searches", json=payload2)
assert response2.status_code == 409
assert "Maximum of 1 saved searches reached" in response2.json()["detail"]
def test_create_saved_search_invalid_name_type(self, client: TestClient):
"""POST /api/saved-searches with non-string name returns 422."""
payload = {
"name": 12345,
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_update_saved_search(self, client: TestClient):
"""PUT /api/saved-searches/{id} updates the saved search."""
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "Original", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
# Update
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Updated", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 200
data = update_resp.json()
assert data["name"] == "Updated"
assert data["filters"]["status"] == "completed"
def test_update_saved_search_not_found(self, client: TestClient):
"""PUT /api/saved-searches/999 returns 404."""
response = client.put(
"/api/saved-searches/999",
json={"name": "Nope", "filters": {"status": "completed"}},
)
assert response.status_code == 404
def test_update_saved_search_duplicate_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with duplicate name returns 409."""
# Create first search
client.post(
"/api/saved-searches",
json={"name": "First Search", "filters": {"status": "pending"}},
)
# Create second search
create_resp2 = client.post(
"/api/saved-searches",
json={"name": "Second Search", "filters": {"status": "completed"}},
)
search_id2 = create_resp2.json()["id"]
# Try to rename second search to "First Search"
update_resp = client.put(
f"/api/saved-searches/{search_id2}",
json={"name": "First Search", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 409
def test_update_saved_search_name_too_long(self, client: TestClient):
"""PUT /api/saved-searches/{id} with name > 100 chars returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "x" * 101, "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty name returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {}},
)
assert update_resp.status_code == 422
def test_update_saved_search_invalid_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with only invalid filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {"invalid_key": "value"}},
)
assert update_resp.status_code == 422
def test_delete_saved_search(self, client: TestClient):
"""DELETE /api/saved-searches/{id} removes the saved search."""
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "To Delete", "filters": {"status": "failed"}},
)
search_id = create_resp.json()["id"]
# Delete
del_resp = client.delete(f"/api/saved-searches/{search_id}")
assert del_resp.status_code == 204
# Verify it's gone
list_resp = client.get("/api/saved-searches")
assert len(list_resp.json()) == 0
def test_delete_saved_search_not_found(self, client: TestClient):
"""DELETE /api/saved-searches/999 returns 404."""
response = client.delete("/api/saved-searches/999")
assert response.status_code == 404
def test_delete_saved_search_db_error(self, client: TestClient):
"""DELETE /api/saved-searches/{id} handles database errors (500)."""
from unittest.mock import patch
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "To Delete DB Error", "filters": {"status": "failed"}},
)
search_id = create_resp.json()["id"]
with patch("sqlalchemy.orm.Session.delete", side_effect=Exception("DB Delete Error")):
response = client.delete(f"/api/saved-searches/{search_id}")
assert response.status_code == 500
assert response.json()["detail"] == "Failed to delete saved search"
def test_create_name_too_long(self, client: TestClient):
"""POST /api/saved-searches with name > 100 chars returns 422."""
payload = {
"name": "x" * 101,
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_saved_search_filters_sanitized(self, client: TestClient):
"""Saved search filters are sanitized to allowed keys only."""
payload = {
"name": "Sanitized",
"filters": {
"search": "invoice",
"mime_type": "application/pdf",
"date_from": "2026-01-01",
"date_to": "2026-12-31",
"storage_provider": "dropbox",
"tags": "invoice,amazon",
"sort_by": "created_at",
"sort_order": "desc",
},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
assert len(data["filters"]) == 8
assert data["filters"]["search"] == "invoice"
assert data["filters"]["tags"] == "invoice,amazon"
def test_saved_search_with_fulltext_query(self, client: TestClient):
"""Saved search can include full-text query (q) for the search view."""
payload = {
"name": "Invoice Search",
"filters": {"q": "invoice total amount", "document_type": "Invoice"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
assert data["filters"]["q"] == "invoice total amount"
assert data["filters"]["document_type"] == "Invoice"
def test_saved_search_content_finding_filters(self, client: TestClient):
"""Saved search accepts content-finding filter keys (language, sender, text_quality)."""
payload = {
"name": "German Invoices",
"filters": {
"q": "rechnung",
"language": "de",
"sender": "ACME GmbH",
"text_quality": "high",
"tags": "invoice",
},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 201
data = response.json()
assert data["filters"]["q"] == "rechnung"
assert data["filters"]["language"] == "de"
assert data["filters"]["sender"] == "ACME GmbH"
assert data["filters"]["text_quality"] == "high"
assert data["filters"]["tags"] == "invoice"
+290
View File
@@ -0,0 +1,290 @@
"""Tests for the classification rules API endpoints.
Covers CRUD operations, validation, and access control for
``/api/classification-rules``.
"""
import pytest
from app.models import ClassificationRuleModel
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_rule(db_session, owner_id="anonymous", **overrides):
"""Insert a ClassificationRuleModel and return it."""
defaults = {
"owner_id": owner_id,
"name": "test_rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": r"(?i)invoice",
"priority": 0,
"case_sensitive": False,
"enabled": True,
}
defaults.update(overrides)
rule = ClassificationRuleModel(**defaults)
db_session.add(rule)
db_session.commit()
db_session.refresh(rule)
return rule
# ---------------------------------------------------------------------------
# Categories & Rule Types endpoints
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCategoriesEndpoint:
"""Tests for GET /api/classification-rules/categories."""
def test_list_categories(self, client):
"""Should return a dict of built-in categories."""
r = client.get("/api/classification-rules/categories")
assert r.status_code == 200
data = r.json()
assert isinstance(data, dict)
assert "invoice" in data
assert "contract" in data
assert "receipt" in data
assert "unknown" in data
@pytest.mark.unit
class TestRuleTypesEndpoint:
"""Tests for GET /api/classification-rules/rule-types."""
def test_list_rule_types(self, client):
"""Should return a list of valid rule types."""
r = client.get("/api/classification-rules/rule-types")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
assert len(data) == 3
type_values = {item["type"] for item in data}
assert "filename_pattern" in type_values
assert "content_keyword" in type_values
assert "metadata_match" in type_values
# ---------------------------------------------------------------------------
# CRUD Operations
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestClassificationRuleCRUD:
"""Full CRUD test-suite for classification rules."""
def test_list_rules_empty(self, client):
"""List returns an empty array when no rules exist."""
r = client.get("/api/classification-rules/")
assert r.status_code == 200
assert r.json() == []
def test_create_rule(self, client):
"""POST should create a new classification rule."""
r = client.post(
"/api/classification-rules/",
json={
"name": "My Invoice Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": r"(?i)rechnung",
"priority": 10,
},
)
assert r.status_code == 201
data = r.json()
assert data["name"] == "My Invoice Rule"
assert data["category"] == "invoice"
assert data["rule_type"] == "filename_pattern"
assert data["priority"] == 10
assert data["enabled"] is True
assert data["id"] is not None
def test_create_rule_invalid_type_rejected(self, client):
"""Creating a rule with an invalid rule_type should be rejected."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Bad Rule",
"category": "test",
"rule_type": "invalid_type",
"pattern": "test",
},
)
assert r.status_code == 400
def test_create_duplicate_name_rejected(self, client):
"""Creating two rules with the same name should be rejected."""
payload = {
"name": "Dupe Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test",
}
r1 = client.post("/api/classification-rules/", json=payload)
assert r1.status_code == 201
r2 = client.post("/api/classification-rules/", json=payload)
assert r2.status_code == 409
def test_get_rule(self, client):
"""GET should return a specific rule by ID."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Get Test Rule",
"category": "contract",
"rule_type": "content_keyword",
"pattern": "agreement|terms",
},
)
rule_id = create_resp.json()["id"]
r = client.get(f"/api/classification-rules/{rule_id}")
assert r.status_code == 200
assert r.json()["name"] == "Get Test Rule"
assert r.json()["category"] == "contract"
def test_get_nonexistent_rule(self, client):
"""GET for a nonexistent rule should return 404."""
r = client.get("/api/classification-rules/99999")
assert r.status_code == 404
def test_update_rule(self, client):
"""PUT should update an existing rule."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Update Test",
"category": "receipt",
"rule_type": "filename_pattern",
"pattern": "receipt",
},
)
rule_id = create_resp.json()["id"]
r = client.put(
f"/api/classification-rules/{rule_id}",
json={"category": "invoice", "priority": 50},
)
assert r.status_code == 200
assert r.json()["category"] == "invoice"
assert r.json()["priority"] == 50
# Name should be unchanged
assert r.json()["name"] == "Update Test"
def test_update_nonexistent_rule(self, client):
"""PUT for a nonexistent rule should return 404."""
r = client.put("/api/classification-rules/99999", json={"category": "test"})
assert r.status_code == 404
def test_update_invalid_rule_type_rejected(self, client):
"""PUT with an invalid rule_type should be rejected."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Invalid Update",
"category": "test",
"rule_type": "filename_pattern",
"pattern": "test",
},
)
rule_id = create_resp.json()["id"]
r = client.put(
f"/api/classification-rules/{rule_id}",
json={"rule_type": "bad_type"},
)
assert r.status_code == 400
def test_delete_rule(self, client):
"""DELETE should remove the rule."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Delete Test",
"category": "test",
"rule_type": "content_keyword",
"pattern": "test",
},
)
rule_id = create_resp.json()["id"]
r = client.delete(f"/api/classification-rules/{rule_id}")
assert r.status_code == 204
# Verify it's gone
r2 = client.get(f"/api/classification-rules/{rule_id}")
assert r2.status_code == 404
def test_delete_nonexistent_rule(self, client):
"""DELETE for a nonexistent rule should return 404."""
r = client.delete("/api/classification-rules/99999")
assert r.status_code == 404
def test_list_rules_after_create(self, client):
"""List should return created rules."""
client.post(
"/api/classification-rules/",
json={
"name": "List Rule 1",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test1",
},
)
client.post(
"/api/classification-rules/",
json={
"name": "List Rule 2",
"category": "contract",
"rule_type": "content_keyword",
"pattern": "test2",
},
)
r = client.get("/api/classification-rules/")
assert r.status_code == 200
assert len(r.json()) == 2
def test_create_rule_with_all_fields(self, client):
"""Create a rule providing all optional fields."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Full Rule",
"category": "tax_document",
"rule_type": "metadata_match",
"pattern": "department=finance",
"priority": 100,
"case_sensitive": True,
"enabled": False,
},
)
assert r.status_code == 201
data = r.json()
assert data["case_sensitive"] is True
assert data["enabled"] is False
assert data["priority"] == 100
def test_create_rule_defaults(self, client):
"""Create a rule with minimal fields to test defaults."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Minimal Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test",
},
)
assert r.status_code == 201
data = r.json()
assert data["priority"] == 0
assert data["case_sensitive"] is False
assert data["enabled"] is True
+250
View File
@@ -416,3 +416,253 @@ 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 TestListDropboxFolders:
"""Tests for list_dropbox_folders endpoint."""
@patch("app.api.dropbox.requests.post")
def test_list_folders_success(self, mock_post, client):
"""Test successful folder listing at root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Documents", "path_display": "/Documents", "id": "id:1"},
{".tag": "folder", "name": "Photos", "path_display": "/Photos", "id": "id:2"},
{".tag": "file", "name": "readme.txt", "path_display": "/readme.txt", "id": "id:3"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 2
assert data["folders"][0]["name"] == "Documents"
assert data["folders"][1]["name"] == "Photos"
assert data["path"] == "/"
assert data["has_more"] is False
@patch("app.api.dropbox.requests.post")
def test_list_folders_subfolder(self, mock_post, client):
"""Test listing folders in a subfolder."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Invoices", "path_display": "/Documents/Invoices", "id": "id:4"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/Documents"},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 1
assert data["folders"][0]["path"] == "/Documents/Invoices"
@patch("app.api.dropbox.requests.post")
def test_list_folders_empty(self, mock_post, client):
"""Test listing folders in an empty directory."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"entries": [], "has_more": False}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/EmptyFolder"},
)
assert response.status_code == 200
assert len(response.json()["folders"]) == 0
@patch("app.api.dropbox.requests.post")
def test_list_folders_unauthorized(self, mock_post, client):
"""Test listing folders with invalid token returns 401."""
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = "Invalid access token"
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "bad-token", "path": ""},
)
assert response.status_code == 401
@patch("app.api.dropbox.requests.post")
def test_list_folders_api_error(self, mock_post, client):
"""Test listing folders when Dropbox API returns an error."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal server error"
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 502
@patch("app.api.dropbox.requests.post")
def test_list_folders_root_path_normalization(self, mock_post, client):
"""Test that '/' is normalized to empty string for Dropbox API."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"entries": [], "has_more": False}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/"},
)
assert response.status_code == 200
# Check the actual API call used empty string for root
call_args = mock_post.call_args
assert call_args[1]["json"]["path"] == ""
@patch("app.api.dropbox.requests.post")
def test_list_folders_sorted_alphabetically(self, mock_post, client):
"""Test that folders are returned in alphabetical order."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Zebra", "path_display": "/Zebra", "id": "id:1"},
{".tag": "folder", "name": "Alpha", "path_display": "/Alpha", "id": "id:2"},
{".tag": "folder", "name": "middle", "path_display": "/middle", "id": "id:3"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
names = [f["name"] for f in response.json()["folders"]]
assert names == ["Alpha", "middle", "Zebra"]
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
File diff suppressed because it is too large Load Diff
+144 -2
View File
@@ -1,5 +1,7 @@
"""Tests for the per-user integrations API (app/api/integrations.py)."""
import unittest.mock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
@@ -887,9 +889,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 +899,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 = {
@@ -984,6 +1063,69 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "scheme" in data["message"].lower()
@unittest.mock.patch("httpx.request")
def test_test_webdav_success(self, mock_request, int_client):
"""WebDAV test succeeds with valid credentials and a valid status code."""
mock_response = unittest.mock.MagicMock()
mock_response.status_code = 207 # Typical WebDAV success for PROPFIND
mock_request.return_value = mock_response
payload = {
"integration_type": "WEBDAV",
"config": {"url": "https://example.com/webdav"},
"credentials": {"username": "user1", "password": "password123"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
mock_request.assert_called_once_with(
"PROPFIND",
"https://example.com/webdav",
auth=("user1", "password123"),
headers={"Depth": "0"},
timeout=10.0,
follow_redirects=False,
)
@unittest.mock.patch("httpx.request")
def test_test_webdav_failure_status(self, mock_request, int_client):
"""WebDAV test fails if the server returns a 4xx or 5xx status code."""
mock_response = unittest.mock.MagicMock()
mock_response.status_code = 401
mock_request.return_value = mock_response
payload = {
"integration_type": "WEBDAV",
"config": {"url": "https://example.com/webdav"},
"credentials": {"username": "user1", "password": "wrong"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "401" in data["message"]
@unittest.mock.patch("httpx.request")
def test_test_webdav_exception(self, mock_request, int_client):
"""WebDAV test fails gracefully if an exception occurs during the request."""
mock_request.side_effect = Exception("Connection error")
payload = {
"integration_type": "WEBDAV",
"config": {"url": "https://example.com/webdav"},
"credentials": {},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "failed" in data["message"].lower()
# ---------------------------------------------------------------------------
# Quota endpoint tests
+66 -2
View File
@@ -329,7 +329,7 @@ class TestDeactivateDevice:
"""Tests for DELETE /api/mobile/devices/{device_id}."""
def test_deactivate_own_device(self, mob_engine, mob_session):
"""Deactivating a device sets is_active to False."""
"""Deactivating an active device sets is_active to False (soft-delete, returns 200)."""
from app.main import app
device = MobileDevice(
@@ -346,7 +346,8 @@ class TestDeactivateDevice:
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 204
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deactivated"
mob_session.expire_all()
updated = mob_session.get(MobileDevice, device_id)
@@ -355,6 +356,33 @@ class TestDeactivateDevice:
finally:
_cleanup(app)
def test_delete_inactive_device(self, mob_engine, mob_session):
"""Deleting an already-inactive device permanently removes it (hard-delete, returns 200)."""
from app.main import app
device = MobileDevice(
owner_id=_OWNER,
push_token=_EXPO_TOKEN,
platform="ios",
is_active=False,
)
mob_session.add(device)
mob_session.commit()
mob_session.refresh(device)
device_id = device.id
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deleted"
mob_session.expire_all()
deleted = mob_session.get(MobileDevice, device_id)
assert deleted is None
finally:
_cleanup(app)
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
"""Attempting to deactivate another user's device returns 404."""
from app.main import app
@@ -439,6 +467,42 @@ class TestWhoAmI:
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
assert data["preferred_language"] is None # not set yet
finally:
_cleanup(app)
def test_whoami_returns_preferred_language(self, mob_engine, mob_session):
"""preferred_language from UserProfile is included in the whoami response."""
from app.main import app
from app.models import UserProfile
profile = UserProfile(
user_id=_OWNER,
display_name="Bob Test",
preferred_language="de",
)
mob_session.add(profile)
mob_session.commit()
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["preferred_language"] == "de"
finally:
_cleanup(app)
def test_whoami_no_profile_preferred_language_is_null(self, mob_engine):
"""preferred_language is null when no UserProfile exists."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["preferred_language"] is None
finally:
_cleanup(app)
+179
View File
@@ -695,3 +695,182 @@ class TestOneDriveIntegration:
# Verify env format is present (exact values may vary)
assert "env_format" in config_data
@pytest.mark.unit
class TestListOneDriveFolders:
"""Tests for list_onedrive_folders endpoint."""
@patch("app.api.onedrive.requests.get")
def test_list_folders_success(self, mock_get, client):
"""Test successful folder listing at root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Documents",
"id": "id:1",
"folder": {"childCount": 3},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "Pictures",
"id": "id:2",
"folder": {"childCount": 10},
"parentReference": {"path": "/drive/root:"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 2
assert data["folders"][0]["name"] == "Documents"
assert data["folders"][0]["path"] == "/Documents"
assert data["folders"][1]["name"] == "Pictures"
assert data["path"] == "/"
@patch("app.api.onedrive.requests.get")
def test_list_folders_subfolder(self, mock_get, client):
"""Test listing folders in a subfolder."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Invoices",
"id": "id:3",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:/Documents"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": "Documents"},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 1
assert data["folders"][0]["path"] == "/Documents/Invoices"
assert data["path"] == "/Documents"
@patch("app.api.onedrive.requests.get")
def test_list_folders_empty(self, mock_get, client):
"""Test listing folders in an empty directory."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"value": []}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": "EmptyFolder"},
)
assert response.status_code == 200
assert len(response.json()["folders"]) == 0
@patch("app.api.onedrive.requests.get")
def test_list_folders_unauthorized(self, mock_get, client):
"""Test listing folders with invalid token returns 401."""
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = "Invalid access token"
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "bad-token", "path": ""},
)
assert response.status_code == 401
@patch("app.api.onedrive.requests.get")
def test_list_folders_api_error(self, mock_get, client):
"""Test listing folders when Graph API returns an error."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal server error"
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 502
@patch("app.api.onedrive.requests.get")
def test_list_folders_sorted_alphabetically(self, mock_get, client):
"""Test that folders are returned in alphabetical order."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Zebra",
"id": "id:1",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "Alpha",
"id": "id:2",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "middle",
"id": "id:3",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
names = [f["name"] for f in response.json()["folders"]]
assert names == ["Alpha", "middle", "Zebra"]
@patch("app.api.onedrive.requests.get")
def test_list_folders_root_drive_parent(self, mock_get, client):
"""Test folder path construction when parentReference.path is /drive/root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "TopLevel",
"id": "id:1",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert data["folders"][0]["path"] == "/TopLevel"
+439
View File
@@ -0,0 +1,439 @@
"""Tests for the session management API endpoints (app/api/sessions.py).
Covers:
* _get_owner_id dependency helper (authenticated and unauthenticated paths)
* GET /api/sessions/ list sessions
* DELETE /api/sessions/{id} revoke a single session
* POST /api/sessions/revoke-all log off everywhere
"""
from __future__ import annotations
import base64
import json
import secrets
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import itsdangerous
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import UserSession
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_OWNER = "sessionuser@example.com"
_OTHER_OWNER = "other@example.com"
_SESSION_SECRET = "test_secret_key_for_testing_must_be_at_least_32_characters_long"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_session_cookie(session_data: dict) -> str:
"""Encode *session_data* as a signed Starlette session cookie value."""
signer = itsdangerous.TimestampSigner(_SESSION_SECRET)
data = base64.b64encode(json.dumps(session_data).encode("utf-8"))
return signer.sign(data).decode("utf-8")
def _make_user_session(
db,
user_id: str = _OWNER,
session_token: str | None = None,
expires_delta: timedelta = timedelta(days=30),
) -> UserSession:
"""Create and persist a UserSession in *db*."""
now = datetime.now(timezone.utc)
token = session_token or secrets.token_urlsafe(32)
session = UserSession(
session_token=token,
user_id=user_id,
ip_address="127.0.0.1",
user_agent="TestBrowser/1.0",
device_info="TestBrowser on Linux",
created_at=now,
last_active_at=now,
expires_at=now + expires_delta,
)
db.add(session)
db.commit()
db.refresh(session)
return session
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def sess_engine():
"""In-memory SQLite engine scoped to one test."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def sess_db(sess_engine):
"""Database session scoped to one test."""
Session = sessionmaker(bind=sess_engine)
session = Session()
yield session
session.close()
def _make_client(sess_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.sessions import _get_owner_id
from app.main import app
Session = sessionmaker(bind=sess_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def _make_unauthenticated_client(sess_engine) -> TestClient:
"""Return a TestClient with only the DB overridden (no auth injection)."""
from app.main import app
Session = sessionmaker(bind=sess_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_get_db
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def _cleanup():
"""Remove all dependency overrides from the app."""
from app.main import app
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests _get_owner_id helper
# ---------------------------------------------------------------------------
class TestGetOwnerId:
"""Tests for the _get_owner_id dependency helper in app/api/sessions.py."""
@pytest.mark.unit
def test_unauthenticated_raises_401(self):
"""_get_owner_id should raise HTTP 401 when the user is not authenticated."""
from unittest.mock import MagicMock
from fastapi import HTTPException
from app.api.sessions import _get_owner_id
mock_request = MagicMock()
with patch("app.api.sessions.get_current_owner_id", return_value=None):
with pytest.raises(HTTPException) as exc_info:
_get_owner_id(mock_request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Not authenticated"
@pytest.mark.unit
def test_authenticated_returns_owner_id(self):
"""_get_owner_id should return the owner_id when the user is authenticated."""
from unittest.mock import MagicMock
from app.api.sessions import _get_owner_id
mock_request = MagicMock()
with patch("app.api.sessions.get_current_owner_id", return_value=_OWNER):
result = _get_owner_id(mock_request)
assert result == _OWNER
# ---------------------------------------------------------------------------
# Tests GET /api/sessions/
# ---------------------------------------------------------------------------
class TestListSessions:
"""Tests for GET /api/sessions/."""
@pytest.mark.unit
def test_list_sessions_empty(self, sess_engine):
"""Returns an empty session list when no sessions exist."""
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
data = resp.json()
assert data["sessions"] == []
assert "session_lifetime_days" in data
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_returns_active_sessions(self, sess_engine, sess_db):
"""Returns session details for all active sessions belonging to the user."""
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
# A session owned by a different user must not appear.
_make_user_session(sess_db, user_id=_OTHER_OWNER)
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
sessions = resp.json()["sessions"]
assert len(sessions) == 2
for s in sessions:
assert "id" in s
assert "device_info" in s
assert "ip_address" in s
assert "created_at" in s
assert "last_active_at" in s
assert "expires_at" in s
assert "is_current" in s
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_marks_current_session(self, sess_engine, sess_db):
"""The session whose token matches request.session['_session_token'] is
marked ``is_current=True``; all others are ``False``."""
current_token = secrets.token_urlsafe(32)
current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token)
other_session = _make_user_session(sess_db, user_id=_OWNER)
cookie = _make_session_cookie({"_session_token": current_token})
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/", cookies={"session": cookie})
assert resp.status_code == 200
sessions = resp.json()["sessions"]
session_map = {s["id"]: s for s in sessions}
assert session_map[current_session.id]["is_current"] is True
assert session_map[other_session.id]["is_current"] is False
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_no_current_token(self, sess_engine, sess_db):
"""When no _session_token is present, all sessions have is_current=False."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
for s in resp.json()["sessions"]:
assert s["is_current"] is False
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_returns_lifetime_days(self, sess_engine):
"""Response always includes session_lifetime_days."""
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
assert isinstance(resp.json()["session_lifetime_days"], int)
assert resp.json()["session_lifetime_days"] >= 1
finally:
_cleanup()
# ---------------------------------------------------------------------------
# Tests DELETE /api/sessions/{session_id}
# ---------------------------------------------------------------------------
class TestRevokeSingleSession:
"""Tests for DELETE /api/sessions/{session_id}."""
@pytest.mark.unit
def test_revoke_session_success(self, sess_engine, sess_db):
"""Revoking an owned session returns 204 No Content."""
session = _make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.delete(f"/api/sessions/{session.id}")
assert resp.status_code == 204
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_not_found(self, sess_engine):
"""Revoking a non-existent session returns 404."""
client = _make_client(sess_engine)
try:
resp = client.delete("/api/sessions/999999")
assert resp.status_code == 404
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_belonging_to_other_user_returns_404(self, sess_engine, sess_db):
"""A user cannot revoke another user's session (returns 404)."""
other_session = _make_user_session(sess_db, user_id=_OTHER_OWNER)
client = _make_client(sess_engine, owner_id=_OWNER)
try:
resp = client.delete(f"/api/sessions/{other_session.id}")
assert resp.status_code == 404
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_audit_failure_does_not_break_response(self, sess_engine, sess_db):
"""Even if the audit service raises an exception, the response is still 204."""
session = _make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")):
resp = client.delete(f"/api/sessions/{session.id}")
assert resp.status_code == 204
finally:
_cleanup()
# ---------------------------------------------------------------------------
# Tests POST /api/sessions/revoke-all
# ---------------------------------------------------------------------------
class TestRevokeAllSessions:
"""Tests for POST /api/sessions/revoke-all."""
@pytest.mark.unit
def test_revoke_all_no_sessions(self, sess_engine):
"""Returns revoked_count=0 when there are no sessions to revoke."""
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
data = resp.json()
assert data["revoked_count"] == 0
assert "message" in data
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_revokes_all_sessions(self, sess_engine, sess_db):
"""All active sessions for the user are revoked."""
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
data = resp.json()
assert data["revoked_count"] == 2
assert "2" in data["message"]
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_preserves_current_session(self, sess_engine, sess_db):
"""The session matching the current _session_token is NOT revoked."""
current_token = secrets.token_urlsafe(32)
current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token)
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
cookie = _make_session_cookie({"_session_token": current_token})
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie})
assert resp.status_code == 200
# Only the two non-current sessions should be revoked.
assert resp.json()["revoked_count"] == 2
# The current session must still be active in the DB.
sess_db.refresh(current_session)
assert current_session.is_revoked is False
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_current_session_token_not_in_db(self, sess_engine, sess_db):
"""When the _session_token in the cookie doesn't match any DB row,
all sessions are revoked (no session is preserved)."""
_make_user_session(sess_db, user_id=_OWNER)
# Cookie references a token that does not exist in the DB.
cookie = _make_session_cookie({"_session_token": "ghost_token_xyz"})
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie})
assert resp.status_code == 200
assert resp.json()["revoked_count"] == 1
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_audit_failure_does_not_break_response(self, sess_engine, sess_db):
"""Even if the audit service raises, revoke-all still returns 200."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")):
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
assert resp.json()["revoked_count"] == 1
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_message_format(self, sess_engine, sess_db):
"""Response message includes the count and mentions API tokens."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
msg = resp.json()["message"]
assert "1" in msg
assert "API" in msg or "token" in msg.lower()
finally:
_cleanup()
+115 -2
View File
@@ -180,6 +180,27 @@ class TestSettingModels:
assert update.key == "test_key"
assert update.value is None
def test_setting_value_update_model(self):
"""Test SettingValueUpdate model (PUT body — no key required)."""
from app.api.settings import SettingValueUpdate
body = SettingValueUpdate(value="test_value")
assert body.value == "test_value"
def test_setting_value_update_model_with_none_value(self):
"""Test SettingValueUpdate model accepts None value."""
from app.api.settings import SettingValueUpdate
body = SettingValueUpdate(value=None)
assert body.value is None
def test_setting_value_update_model_defaults_to_none(self):
"""Test SettingValueUpdate model value defaults to None when omitted."""
from app.api.settings import SettingValueUpdate
body = SettingValueUpdate()
assert body.value is None
def test_setting_response_model(self):
"""Test SettingResponse model."""
from app.api.settings import SettingResponse
@@ -205,8 +226,100 @@ class TestSettingModels:
assert "test_key" in response.db_settings
@pytest.mark.unit
class TestListCredentials:
@pytest.mark.integration
class TestPutSettingEndpoint:
"""Tests for PUT /api/settings/{key} endpoint."""
def test_put_setting_requires_admin(self, client):
"""Test PUT /settings/{key} requires admin access."""
response = client.put("/api/settings/social_auth_dropbox_enabled", json={"value": "true"})
assert response.status_code in [302, 401, 403]
@patch("app.api.settings.notify_settings_updated")
@patch("app.api.settings.get_setting_metadata")
@patch("app.api.settings.validate_setting_value")
@patch("app.api.settings.save_setting_to_db")
def test_put_setting_saves_value(self, mock_save, mock_validate, mock_metadata, mock_notify, client):
"""Test PUT /settings/{key} saves the value when authenticated as admin."""
from app.api.settings import require_admin
from app.main import app as fastapi_app
mock_validate.return_value = (True, None)
mock_save.return_value = True
mock_metadata.return_value = {"restart_required": True}
def override_require_admin():
return {"id": "admin", "is_admin": True, "preferred_username": "admin"}
fastapi_app.dependency_overrides[require_admin] = override_require_admin
try:
response = client.put(
"/api/settings/social_auth_dropbox_enabled",
json={"value": "true"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["key"] == "social_auth_dropbox_enabled"
assert data["value"] == "true"
assert data["restart_required"] is True
finally:
fastapi_app.dependency_overrides.pop(require_admin, None)
@patch("app.api.settings.notify_settings_updated")
@patch("app.api.settings.get_setting_metadata")
@patch("app.api.settings.validate_setting_value")
@patch("app.api.settings.save_setting_to_db")
def test_put_setting_body_without_key_field_is_accepted(
self, mock_save, mock_validate, mock_metadata, mock_notify, client
):
"""Test PUT /settings/{key} body need not contain a key field."""
from app.api.settings import require_admin
from app.main import app as fastapi_app
mock_validate.return_value = (True, None)
mock_save.return_value = True
mock_metadata.return_value = {"restart_required": False}
def override_require_admin():
return {"id": "admin", "is_admin": True, "preferred_username": "admin"}
fastapi_app.dependency_overrides[require_admin] = override_require_admin
try:
# Body only contains "value" — no "key" field (mirrors admin_connections.html behaviour)
response = client.put(
"/api/settings/social_auth_dropbox_use_global_credentials",
json={"value": "false"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
finally:
fastapi_app.dependency_overrides.pop(require_admin, None)
@patch("app.api.settings.validate_setting_value")
@patch("app.api.settings.get_setting_metadata")
def test_put_setting_returns_400_on_invalid_value(self, mock_metadata, mock_validate, client):
"""Test PUT /settings/{key} returns 400 for invalid values."""
from app.api.settings import require_admin
from app.main import app as fastapi_app
mock_validate.return_value = (False, "Invalid boolean value")
mock_metadata.return_value = {"restart_required": False}
def override_require_admin():
return {"id": "admin", "is_admin": True}
fastapi_app.dependency_overrides[require_admin] = override_require_admin
try:
response = client.put(
"/api/settings/social_auth_dropbox_enabled",
json={"value": "not_a_bool"},
)
assert response.status_code == 400
finally:
fastapi_app.dependency_overrides.pop(require_admin, None)
"""Tests for the list_credentials function (GET /api/settings/credentials)."""
@patch("app.api.settings.get_all_settings_from_db")
+223 -4
View File
@@ -314,8 +314,8 @@ class TestTokenRevoke:
_cleanup(app)
@pytest.mark.unit
def test_revoke_already_revoked_token(self, tok_engine):
"""Revoking an already-revoked token should return 400."""
def test_delete_already_revoked_token(self, tok_engine):
"""Deleting an already-revoked token should permanently remove it (hard-delete, 200)."""
from app.main import app
client = _make_client(tok_engine)
@@ -324,9 +324,15 @@ class TestTokenRevoke:
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
# Second DELETE should hard-delete the revoked token.
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already revoked"
assert resp.status_code == 200
assert resp.json()["detail"] == "Token deleted"
# Token must no longer appear in the list.
list_resp = client.get("/api/api-tokens/")
ids = [t["id"] for t in list_resp.json()]
assert token_id not in ids
finally:
_cleanup(app)
@@ -677,3 +683,216 @@ class TestTokenUtils:
token = "de_test_token_value"
expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e"
assert hash_token(token) == expected_hash
# ---------------------------------------------------------------------------
# Tests Token reactivation
# ---------------------------------------------------------------------------
class TestTokenReactivate:
"""Tests for POST /api/api-tokens/{id}/reactivate."""
@pytest.mark.unit
def test_reactivate_revoked_token(self, tok_engine):
"""Reactivating a revoked token should set is_active=True and clear revoked_at."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"})
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 200
data = resp.json()
assert data["is_active"] is True
assert data["revoked_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_active_token_returns_400(self, tok_engine):
"""Reactivating an already-active token should return 400."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"})
token_id = create_resp.json()["id"]
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already active"
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_nonexistent_token(self, tok_engine):
"""Reactivating a non-existent token should return 404."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/99999/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_other_users_token(self, tok_engine):
"""A user cannot reactivate another user's token."""
from app.main import app
client_a = _make_client(tok_engine, _OWNER)
try:
create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"})
token_id = create_resp.json()["id"]
client_a.delete(f"/api/api-tokens/{token_id}")
finally:
_cleanup(app)
client_b = _make_client(tok_engine, _OTHER_OWNER)
try:
resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Token lifetime (expires_at)
# ---------------------------------------------------------------------------
class TestTokenExpiry:
"""Tests for token creation with optional lifetime and expiry enforcement."""
@pytest.mark.unit
def test_create_token_without_expiry(self, tok_engine):
"""Creating a token without expires_in_days should leave expires_at as None."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "No Expiry"})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_with_expiry(self, tok_engine, tok_session):
"""Creating a token with expires_in_days should set expires_at in the future."""
from datetime import datetime, timezone
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is not None
# Parse the returned datetime; handle both tz-aware and tz-naive serialisations
expires_str = data["expires_at"].replace("Z", "+00:00")
expires_at = datetime.fromisoformat(expires_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
delta_days = (expires_at - now).days
assert 28 <= delta_days <= 30
finally:
_cleanup(app)
@pytest.mark.unit
def test_expired_token_not_resolved(self, tok_engine, tok_session):
"""A token past its expires_at should not authenticate."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Expired Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is None
@pytest.mark.unit
def test_non_expired_token_resolves(self, tok_engine, tok_session):
"""A token before its expires_at should authenticate normally."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Valid Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is not None
assert user["preferred_username"] == _OWNER
@pytest.mark.unit
def test_create_token_expires_in_days_zero_rejected(self, tok_engine):
"""expires_in_days=0 should be rejected with 422 (ge=1)."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0})
assert resp.status_code == 422
finally:
_cleanup(app)
@pytest.mark.unit
def test_expires_at_included_in_list_response(self, tok_engine):
"""List endpoint should include expires_at field."""
from app.main import app
client = _make_client(tok_engine)
try:
client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7})
resp = client.get("/api/api-tokens/")
assert resp.status_code == 200
tokens = resp.json()
assert len(tokens) == 1
assert "expires_at" in tokens[0]
assert tokens[0]["expires_at"] is not None
finally:
_cleanup(app)
+96 -1
View File
@@ -5,12 +5,15 @@ Covers the audit service (recording, querying, SIEM forwarding),
the REST API endpoints, and the admin viewer page.
"""
import base64
import json
import socket
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, Mock, PropertyMock, patch
import pytest
from fastapi import HTTPException
from itsdangerous import TimestampSigner
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
@@ -645,6 +648,16 @@ class TestAuditLogAPI:
# View tests
# ---------------------------------------------------------------------------
_TEST_SESSION_SECRET = "test_secret_key_for_testing_must_be_at_least_32_characters_long"
def _make_admin_session_cookie() -> str:
"""Create a signed session cookie with admin user data for integration tests."""
session_data = {"user": {"id": "admin", "is_admin": True}}
signer = TimestampSigner(_TEST_SESSION_SECRET)
data = base64.b64encode(json.dumps(session_data).encode()).decode("utf-8")
return signer.sign(data).decode("utf-8")
@pytest.mark.integration
class TestAuditLogView:
@@ -655,3 +668,85 @@ class TestAuditLogView:
resp = client.get("/admin/audit-logs")
assert resp.status_code == 200
assert "Audit Logs" in resp.text
def test_audit_logs_page_accessible_with_admin_session(self, client):
"""GET /admin/audit-logs with admin session cookie returns 200."""
client.cookies.set("session", _make_admin_session_cookie())
resp = client.get("/admin/audit-logs", follow_redirects=False)
assert resp.status_code == 200
assert "Audit Logs" in resp.text
def test_audit_logs_page_redirects_non_admin(self, client):
"""GET /admin/audit-logs without admin session redirects to home."""
resp = client.get("/admin/audit-logs", follow_redirects=False)
assert resp.status_code == 302
@pytest.mark.unit
class TestAuditLogsPageUnit:
"""Unit tests for the audit_logs_page view function (lines 29-43)."""
@patch("app.views.audit_logs.templates")
@patch("app.views.audit_logs.settings")
@pytest.mark.asyncio
async def test_audit_logs_page_siem_disabled(self, mock_settings, mock_templates):
"""Renders the template with siem_transport=None when SIEM is disabled."""
from app.views.audit_logs import audit_logs_page
mock_settings.audit_siem_enabled = False
mock_settings.version = "2.0.0"
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
mock_db = Mock()
await audit_logs_page(mock_request, mock_db)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "audit_logs.html"
context = call_args[0][1]
assert context["siem_enabled"] is False
assert context["siem_transport"] is None
assert context["app_version"] == "2.0.0"
@patch("app.views.audit_logs.templates")
@patch("app.views.audit_logs.settings")
@pytest.mark.asyncio
async def test_audit_logs_page_siem_enabled(self, mock_settings, mock_templates):
"""Renders the template with siem_transport set when SIEM is enabled."""
from app.views.audit_logs import audit_logs_page
mock_settings.audit_siem_enabled = True
mock_settings.audit_siem_transport = "syslog"
mock_settings.version = "2.0.0"
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
mock_db = Mock()
await audit_logs_page(mock_request, mock_db)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
assert context["siem_enabled"] is True
assert context["siem_transport"] == "syslog"
@patch("app.views.audit_logs.settings")
@pytest.mark.asyncio
async def test_audit_logs_page_raises_500_on_error(self, mock_settings):
"""Raises HTTP 500 when an unexpected error occurs while loading the page."""
from app.views.audit_logs import audit_logs_page
# Make accessing audit_siem_enabled raise an exception to trigger the except branch
type(mock_settings).audit_siem_enabled = PropertyMock(side_effect=RuntimeError("settings unavailable"))
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
mock_db = Mock()
with pytest.raises(HTTPException) as exc_info:
await audit_logs_page(mock_request, mock_db)
assert exc_info.value.status_code == 500
assert "Failed to load audit logs page" in exc_info.value.detail
File diff suppressed because it is too large Load Diff
+432
View File
@@ -0,0 +1,432 @@
"""Tests for the Zapier / Make.com automation integration.
Covers:
- Automation hook utility functions (payload builder, DB queries, dispatch)
- Automation hook Celery task
- REST hooks API endpoints (subscribe, unsubscribe, list, sample, events)
- Incoming action endpoints (upload)
- Integration with existing webhook dispatch
"""
import json
import time
from unittest.mock import MagicMock
import pytest
from app.models import AutomationHook
from app.utils.automation_hooks import (
SAMPLE_PAYLOADS,
build_zapier_payload,
dispatch_automation_hooks,
get_active_hooks_for_event,
)
from app.utils.webhook import VALID_EVENTS
# ---------------------------------------------------------------------------
# Unit tests build_zapier_payload
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuildZapierPayload:
"""Tests for the Zapier-compatible payload builder."""
def test_contains_required_keys(self):
"""Payload must contain id, event, timestamp, plus data fields."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert "id" in payload
assert "event" in payload
assert "timestamp" in payload
assert "document_id" in payload
def test_id_starts_with_evt(self):
"""ID field must start with 'evt_' for Zapier deduplication."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert payload["id"].startswith("evt_")
def test_event_matches_input(self):
"""Event field must match the event argument."""
payload = build_zapier_payload("document.processed", {"document_id": 2})
assert payload["event"] == "document.processed"
def test_timestamp_is_recent(self):
"""Timestamp should be close to current time."""
before = time.time()
payload = build_zapier_payload("document.uploaded", {})
after = time.time()
assert before <= payload["timestamp"] <= after
def test_data_is_flat(self):
"""Data fields should be merged into top level (flat, no nested 'data' key)."""
payload = build_zapier_payload("document.uploaded", {"filename": "test.pdf", "size": 1024})
assert payload["filename"] == "test.pdf"
assert payload["size"] == 1024
assert "data" not in payload
def test_unique_ids(self):
"""Each call should produce a unique ID."""
ids = {build_zapier_payload("document.uploaded", {})["id"] for _ in range(50)}
assert len(ids) == 50
# ---------------------------------------------------------------------------
# Unit tests SAMPLE_PAYLOADS
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSamplePayloads:
"""Tests for the sample payloads used by Zapier field mapping."""
def test_all_events_have_samples(self):
"""Every valid event should have a sample payload."""
for event in VALID_EVENTS:
assert event in SAMPLE_PAYLOADS, f"Missing sample payload for {event}"
def test_samples_contain_id_and_event(self):
"""Each sample should contain id and event keys."""
for event, sample in SAMPLE_PAYLOADS.items():
assert "id" in sample, f"Sample for {event} missing 'id'"
assert sample["event"] == event
# ---------------------------------------------------------------------------
# Unit tests get_active_hooks_for_event
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetActiveHooksForEvent:
"""Tests for querying active automation hooks from the database."""
def test_returns_matching_hooks(self, mocker):
"""Only hooks subscribed to the event should be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret="abc",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 1
assert result[0]["target_url"] == "https://hooks.zapier.com/1234"
def test_excludes_non_matching_hooks(self, mocker):
"""Hooks for different events should not be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret=None,
events=json.dumps(["document.processed"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 0
def test_empty_when_no_hooks(self, mocker):
"""Empty list returned when no hooks exist."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = []
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert result == []
# ---------------------------------------------------------------------------
# Unit tests dispatch_automation_hooks
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDispatchAutomationHooks:
"""Tests for the automation hook dispatch function."""
def test_ignores_unknown_events(self, mocker):
"""Unknown events should be silently ignored."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("bad.event", {})
mock_get.assert_not_called()
def test_skips_when_disabled(self, mocker):
"""No hooks should fire when automation_hooks_enabled is False."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=False))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("document.uploaded", {"file_id": 1})
mock_get.assert_not_called()
def test_queues_celery_task_for_each_hook(self, mocker):
"""A Celery task is queued for each matching hook."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch(
"app.utils.automation_hooks.get_active_hooks_for_event",
return_value=[
{"id": 1, "target_url": "https://hooks.zapier.com/a", "secret": "s", "events": ["document.uploaded"]},
{"id": 2, "target_url": "https://hooks.zapier.com/b", "secret": None, "events": ["document.uploaded"]},
],
)
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {"file_id": 42})
assert mock_task.call_count == 2
def test_no_tasks_when_no_hooks(self, mocker):
"""No tasks should be queued when there are no matching hooks."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event", return_value=[])
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {})
mock_task.assert_not_called()
# ---------------------------------------------------------------------------
# Unit tests deliver_automation_hook_task
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeliverAutomationHookTask:
"""Tests for the automation hook Celery task."""
def test_returns_success_dict(self, mocker):
"""Successful delivery returns status dict."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=True)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
result = deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
assert result["status"] == "delivered"
assert result["url"] == "https://hooks.zapier.com/test"
def test_raises_on_failure(self, mocker):
"""Failed delivery raises RuntimeError for Celery retry."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=False)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
with pytest.raises(RuntimeError, match="Automation hook delivery"):
deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
# ---------------------------------------------------------------------------
# Integration tests webhook dispatch integration
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestWebhookDispatchIntegration:
"""Test that dispatch_webhook_event also triggers automation hooks."""
def test_dispatch_triggers_automation_hooks(self, mocker):
"""dispatch_webhook_event should also call dispatch_automation_hooks."""
mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[])
mock_auto = mocker.patch("app.utils.automation_hooks.dispatch_automation_hooks")
from app.utils.webhook import dispatch_webhook_event
dispatch_webhook_event("document.uploaded", {"file_id": 1})
mock_auto.assert_called_once_with("document.uploaded", {"file_id": 1})
# ---------------------------------------------------------------------------
# Integration tests API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestAutomationAPI:
"""Tests for the /api/automation/ endpoints."""
def _with_auth(self, client):
"""Override auth dependency to simulate an authenticated user."""
from app.api.automation import _require_api_user
client.app.dependency_overrides[_require_api_user] = lambda: {
"id": "testuser",
"email": "test@example.com",
"preferred_username": "testuser",
"is_admin": False,
}
return client
# ── Subscribe / Unsubscribe ──────────────────────────────────────
def test_subscribe_hook(self, client):
"""POST /api/automation/hooks/subscribe creates a new hook."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/test",
"events": ["document.uploaded"],
"hook_type": "zapier",
"description": "My Zap",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["target_url"] == "https://hooks.zapier.com/test"
assert data["events"] == ["document.uploaded"]
assert data["is_active"] is True
assert data["hook_type"] == "zapier"
def test_subscribe_with_secret(self, client):
"""POST /api/automation/hooks/subscribe with secret masks it."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/secret",
"events": ["document.processed"],
"secret": "my-signing-secret",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["has_secret"] is True
assert "secret" not in data
def test_subscribe_invalid_event(self, client):
"""POST /api/automation/hooks/subscribe rejects invalid events."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/bad",
"events": ["bad.event"],
},
)
assert resp.status_code == 422
def test_unsubscribe_hook(self, client, db_session):
"""DELETE /api/automation/hooks/{id} removes the hook."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/del",
events=json.dumps(["document.uploaded"]),
is_active=True,
hook_type="zapier",
)
db_session.add(hook)
db_session.commit()
hook_id = hook.id
resp = client.delete(f"/api/automation/hooks/{hook_id}")
assert resp.status_code == 204
def test_unsubscribe_not_found(self, client):
"""DELETE /api/automation/hooks/9999 returns 404."""
self._with_auth(client)
resp = client.delete("/api/automation/hooks/9999")
assert resp.status_code == 404
# ── List hooks ───────────────────────────────────────────────────
def test_list_hooks(self, client, db_session):
"""GET /api/automation/hooks returns all hooks."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/list",
events=json.dumps(["document.processed"]),
is_active=True,
hook_type="make",
)
db_session.add(hook)
db_session.commit()
resp = client.get("/api/automation/hooks")
assert resp.status_code == 200
items = resp.json()
assert any(h["target_url"] == "https://hooks.zapier.com/list" for h in items)
# ── Sample trigger data ──────────────────────────────────────────
def test_trigger_sample(self, client):
"""GET /api/automation/triggers/sample/{event} returns sample data."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/document.uploaded")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["event"] == "document.uploaded"
assert "id" in data[0]
def test_trigger_sample_unknown_event(self, client):
"""GET /api/automation/triggers/sample/bad returns 404."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/bad.event")
assert resp.status_code == 404
# ── Events listing ───────────────────────────────────────────────
def test_list_events(self, client):
"""GET /api/automation/events returns valid event types."""
self._with_auth(client)
resp = client.get("/api/automation/events")
assert resp.status_code == 200
events = resp.json()
assert "document.uploaded" in events
assert "document.processed" in events
assert "document.failed" in events
# ── Incoming action: upload ──────────────────────────────────────
def test_action_upload(self, client, mocker):
"""POST /api/automation/actions/upload accepts a file."""
self._with_auth(client)
mock_task = MagicMock()
mock_task.id = "task-123"
mocker.patch("app.tasks.process_document.process_document.delay", return_value=mock_task)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("test.pdf", b"fake-pdf-content", "application/pdf")},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "accepted"
assert data["filename"] == "test.pdf"
assert data["task_id"] == "task-123"
def test_action_upload_no_filename(self, client):
"""POST /api/automation/actions/upload rejects empty filename."""
self._with_auth(client)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("", b"content", "application/pdf")},
)
# FastAPI/Starlette may return 422 (multipart validation) or 400
# (our explicit check) depending on how the empty filename is
# parsed by the underlying multipart parser version.
assert resp.status_code in (400, 422)
# ── Auth required ────────────────────────────────────────────────
def test_requires_auth(self, client):
"""Endpoints return 401 without authentication."""
from app.api.automation import _require_api_user
client.app.dependency_overrides.pop(_require_api_user, None)
resp = client.get("/api/automation/hooks")
assert resp.status_code == 401
+422
View File
@@ -0,0 +1,422 @@
"""Tests for the rule-based document classification engine.
Covers the classification engine logic in ``app/utils/classification_rules.py``:
built-in rules, custom rules, confidence scoring, and edge cases.
"""
import pytest
from app.utils.classification_rules import (
BUILTIN_CATEGORIES,
BUILTIN_RULES,
RULE_TYPE_CONTENT,
RULE_TYPE_FILENAME,
RULE_TYPE_METADATA,
ClassificationResult,
ClassificationRule,
MatchedRule,
classify_document,
db_rule_to_engine_rule,
)
# ---------------------------------------------------------------------------
# Built-in categories & rules smoke tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuiltinCategories:
"""Verify the pre-built categories and rules are sane."""
def test_builtin_categories_not_empty(self):
"""There must be at least one built-in category."""
assert len(BUILTIN_CATEGORIES) > 0
def test_unknown_category_exists(self):
"""The 'unknown' fallback category must be present."""
assert "unknown" in BUILTIN_CATEGORIES
def test_core_categories_present(self):
"""Invoice, contract, and receipt categories must exist."""
for cat in ("invoice", "contract", "receipt"):
assert cat in BUILTIN_CATEGORIES, f"Missing built-in category: {cat}"
def test_builtin_rules_not_empty(self):
"""There must be at least one built-in rule."""
assert len(BUILTIN_RULES) > 0
def test_all_builtin_rules_reference_valid_types(self):
"""Every built-in rule must use a valid rule_type."""
valid_types = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA}
for rule in BUILTIN_RULES:
assert rule.rule_type in valid_types, f"Rule {rule.name!r} has invalid type {rule.rule_type!r}"
# ---------------------------------------------------------------------------
# ClassificationRule dataclass validation
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassificationRuleValidation:
"""Test ClassificationRule dataclass validation."""
def test_valid_rule_types(self):
"""Valid rule types should not raise."""
for rt in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA):
rule = ClassificationRule(name="test", category="test", rule_type=rt, pattern="test")
assert rule.rule_type == rt
def test_invalid_rule_type_raises(self):
"""An invalid rule_type should raise ValueError."""
with pytest.raises(ValueError, match="Invalid rule_type"):
ClassificationRule(name="test", category="test", rule_type="invalid", pattern="test")
# ---------------------------------------------------------------------------
# Filename pattern matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFilenamePatternMatching:
"""Test classification via filename patterns."""
def test_invoice_filename(self):
"""A filename containing 'invoice' should classify as invoice."""
result = classify_document(filename="2024-03-01_Invoice_Acme.pdf")
assert result.category == "invoice"
assert result.confidence > 0
def test_german_invoice_filename(self):
"""A filename containing 'Rechnung' should classify as invoice."""
result = classify_document(filename="Rechnung_2024.pdf")
assert result.category == "invoice"
assert result.confidence > 0
def test_contract_filename(self):
"""A filename containing 'contract' should classify as contract."""
result = classify_document(filename="Service_Contract_2024.pdf")
assert result.category == "contract"
def test_receipt_filename(self):
"""A filename containing 'receipt' should classify as receipt."""
result = classify_document(filename="Payment_Receipt.pdf")
assert result.category == "receipt"
def test_unrecognized_filename(self):
"""A generic filename with no keywords should return 'unknown'."""
result = classify_document(filename="document_12345.pdf")
assert result.category == "unknown"
assert result.confidence == 0
def test_empty_filename(self):
"""An empty filename should not match any rule."""
result = classify_document(filename="")
assert result.category == "unknown"
# ---------------------------------------------------------------------------
# Content keyword matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestContentKeywordMatching:
"""Test classification via content keywords."""
def test_invoice_content(self):
"""Text containing 'invoice number' should classify as invoice."""
result = classify_document(text="Please pay the invoice number 12345. Amount due: $500")
assert result.category == "invoice"
assert result.confidence > 0
def test_contract_content(self):
"""Text containing 'terms and conditions' should classify as contract."""
result = classify_document(text="The parties hereby agree to the following terms and conditions.")
assert result.category == "contract"
def test_receipt_content(self):
"""Text containing 'payment received' should classify as receipt."""
result = classify_document(text="Thank you. Payment received for order #789.")
assert result.category == "receipt"
def test_bank_statement_content(self):
"""Text containing 'account statement' should classify as bank_statement."""
result = classify_document(text="Monthly account statement. Opening balance: $1,000.")
assert result.category == "bank_statement"
def test_empty_text(self):
"""Empty text should not match any content rule."""
result = classify_document(text="")
assert result.category == "unknown"
def test_case_insensitive_matching(self):
"""Content matching should be case-insensitive by default."""
result = classify_document(text="INVOICE NUMBER 12345")
assert result.category == "invoice"
# ---------------------------------------------------------------------------
# Metadata matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMetadataMatching:
"""Test classification via metadata field matching."""
def test_document_type_invoice(self):
"""metadata document_type=Invoice should classify as invoice."""
result = classify_document(metadata={"document_type": "Invoice"})
assert result.category == "invoice"
assert result.confidence >= 90
def test_document_type_contract(self):
"""metadata document_type=Contract should classify as contract."""
result = classify_document(metadata={"document_type": "Contract"})
assert result.category == "contract"
def test_kommunikationsart_rechnung(self):
"""German classification metadata should classify as invoice."""
result = classify_document(metadata={"kommunikationsart": "Rechnung"})
assert result.category == "invoice"
def test_no_metadata(self):
"""None metadata should not match."""
result = classify_document(metadata=None)
assert result.category == "unknown"
def test_empty_metadata(self):
"""Empty metadata dict should not match."""
result = classify_document(metadata={})
assert result.category == "unknown"
def test_metadata_case_insensitive(self):
"""Metadata matching should be case-insensitive by default."""
result = classify_document(metadata={"document_type": "invoice"})
assert result.category == "invoice"
# ---------------------------------------------------------------------------
# Combined matching / confidence boosting
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCombinedMatching:
"""Test that multiple matching rules boost confidence."""
def test_filename_and_content_boost(self):
"""Filename + content matching should produce higher confidence than either alone."""
filename_only = classify_document(filename="Invoice_2024.pdf")
combined = classify_document(filename="Invoice_2024.pdf", text="Invoice number: 12345. Amount due: $500.")
assert combined.confidence >= filename_only.confidence
assert len(combined.matched_rules) > len(filename_only.matched_rules)
def test_all_three_signals(self):
"""Filename + content + metadata should produce highest confidence."""
result = classify_document(
filename="Invoice_Acme.pdf",
text="Invoice number: 12345. Amount due: $500.",
metadata={"document_type": "Invoice"},
)
assert result.category == "invoice"
assert result.confidence >= 90
def test_conflicting_signals_most_matches_wins(self):
"""When filename says 'invoice' but content says 'contract', most matches wins."""
result = classify_document(
filename="Invoice.pdf",
text="The parties hereby agree to the following terms and conditions. "
"This agreement between Company A and Company B is effective immediately.",
)
# Content has more keyword matches for contract, but filename matches invoice.
# Either is acceptable as long as the result is deterministic.
assert result.category in ("invoice", "contract")
# ---------------------------------------------------------------------------
# Custom rules
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCustomRules:
"""Test user-defined custom classification rules."""
def test_custom_rule_matches(self):
"""A custom filename rule should match when pattern hits."""
custom = [
ClassificationRule(
name="custom_hr_doc",
category="hr_document",
rule_type=RULE_TYPE_FILENAME,
pattern=r"(?i)employee|hiring|hr",
)
]
result = classify_document(filename="Employee_Handbook.pdf", custom_rules=custom)
assert result.category == "hr_document"
def test_custom_content_rule(self):
"""A custom content keyword rule should match."""
custom = [
ClassificationRule(
name="custom_medical",
category="medical",
rule_type=RULE_TYPE_CONTENT,
pattern="diagnosis|prescription|patient record",
)
]
result = classify_document(text="Patient record for Jane Doe. Diagnosis: common cold.", custom_rules=custom)
assert result.category == "medical"
def test_custom_metadata_rule(self):
"""A custom metadata rule should match."""
custom = [
ClassificationRule(
name="custom_legal",
category="legal",
rule_type=RULE_TYPE_METADATA,
pattern="department=legal",
)
]
result = classify_document(metadata={"department": "legal"}, custom_rules=custom)
assert result.category == "legal"
def test_custom_rule_overrides_builtin(self):
"""Custom rules with more matches should override built-in rules."""
custom = [
ClassificationRule(
name="custom_internal_invoice",
category="internal_invoice",
rule_type=RULE_TYPE_FILENAME,
pattern=r"(?i)invoice",
priority=100,
),
ClassificationRule(
name="custom_internal_invoice_content",
category="internal_invoice",
rule_type=RULE_TYPE_CONTENT,
pattern="invoice number",
priority=100,
),
]
result = classify_document(
filename="Invoice_2024.pdf",
text="Invoice number: 12345",
custom_rules=custom,
)
# Both builtin and custom rules for "invoice" patterns match, but custom
# has "internal_invoice" as category. The category with more total matches wins.
assert result.category in ("invoice", "internal_invoice")
assert result.confidence > 0
# ---------------------------------------------------------------------------
# db_rule_to_engine_rule converter
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDbRuleConversion:
"""Test the database model to engine rule converter."""
def test_converts_basic_fields(self):
"""All basic fields should be mapped correctly."""
class FakeDbRule:
name = "test_rule"
category = "invoice"
rule_type = RULE_TYPE_FILENAME
pattern = r"(?i)invoice"
priority = 10
case_sensitive = True
engine_rule = db_rule_to_engine_rule(FakeDbRule())
assert engine_rule.name == "test_rule"
assert engine_rule.category == "invoice"
assert engine_rule.rule_type == RULE_TYPE_FILENAME
assert engine_rule.pattern == r"(?i)invoice"
assert engine_rule.priority == 10
assert engine_rule.case_sensitive is True
def test_defaults_case_sensitive_to_false(self):
"""When case_sensitive is missing, default to False."""
class FakeDbRule:
name = "test"
category = "test"
rule_type = RULE_TYPE_CONTENT
pattern = "test"
priority = 0
engine_rule = db_rule_to_engine_rule(FakeDbRule())
assert engine_rule.case_sensitive is False
# ---------------------------------------------------------------------------
# ClassificationResult
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassificationResult:
"""Test the ClassificationResult dataclass."""
def test_default_matched_rules(self):
"""matched_rules should default to an empty list."""
result = ClassificationResult(category="test", confidence=50)
assert result.matched_rules == []
def test_with_matched_rules(self):
"""matched_rules should be populated when provided."""
match = MatchedRule(rule_name="test", rule_type=RULE_TYPE_FILENAME, category="invoice", confidence=60)
result = ClassificationResult(category="invoice", confidence=60, matched_rules=[match])
assert len(result.matched_rules) == 1
assert result.matched_rules[0].rule_name == "test"
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestEdgeCases:
"""Test edge cases in the classification engine."""
def test_no_inputs_at_all(self):
"""No filename, text, or metadata should return 'unknown'."""
result = classify_document()
assert result.category == "unknown"
assert result.confidence == 0
assert result.matched_rules == []
def test_metadata_pattern_without_equals(self):
"""A metadata pattern without '=' should not match."""
custom = [
ClassificationRule(
name="bad_pattern",
category="test",
rule_type=RULE_TYPE_METADATA,
pattern="no_equals_sign",
)
]
result = classify_document(metadata={"no_equals_sign": "value"}, custom_rules=custom)
assert result.category == "unknown"
def test_confidence_capped_at_100(self):
"""Confidence should never exceed 100."""
# Create many rules that all match to test the cap
custom = [
ClassificationRule(
name=f"flood_{i}",
category="flood",
rule_type=RULE_TYPE_CONTENT,
pattern="test keyword",
)
for i in range(20)
]
result = classify_document(text="test keyword is here", custom_rules=custom)
assert result.confidence <= 100
+232
View File
@@ -0,0 +1,232 @@
"""Tests for the classify_document Celery task.
Covers the ``classify_document_task`` in ``app/tasks/classify_document.py``.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from app.models import ClassificationRuleModel, FileRecord
from app.tasks.classify_document import _load_custom_rules
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_file_record(db_session, **overrides):
"""Insert a minimal FileRecord and return it."""
defaults = {
"owner_id": "test-user",
"filehash": "abc123",
"original_filename": "Invoice_2024.pdf",
"local_filename": "/tmp/test.pdf",
"file_size": 1024,
"mime_type": "application/pdf",
"ocr_text": "Invoice number: 12345. Amount due: $500.",
"ai_metadata": None,
}
defaults.update(overrides)
fr = FileRecord(**defaults)
db_session.add(fr)
db_session.commit()
db_session.refresh(fr)
return fr
def _make_rule(db_session, **overrides):
"""Insert a ClassificationRuleModel and return it."""
defaults = {
"owner_id": None,
"name": "test_rule",
"category": "test_category",
"rule_type": "filename_pattern",
"pattern": r"(?i)test",
"priority": 0,
"case_sensitive": False,
"enabled": True,
}
defaults.update(overrides)
rule = ClassificationRuleModel(**defaults)
db_session.add(rule)
db_session.commit()
db_session.refresh(rule)
return rule
# ---------------------------------------------------------------------------
# _load_custom_rules
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestLoadCustomRules:
"""Test the custom rule loading helper."""
@patch("app.tasks.classify_document.SessionLocal")
def test_loads_enabled_rules(self, mock_session_local):
"""Should load enabled rules from the database."""
mock_rule = MagicMock()
mock_rule.name = "rule1"
mock_rule.category = "invoice"
mock_rule.rule_type = "filename_pattern"
mock_rule.pattern = r"(?i)invoice"
mock_rule.priority = 10
mock_rule.case_sensitive = False
mock_db = MagicMock()
mock_query = MagicMock()
mock_db.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [mock_rule]
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
rules = _load_custom_rules(owner_id="test-user")
assert len(rules) == 1
assert rules[0].name == "rule1"
assert rules[0].category == "invoice"
# ---------------------------------------------------------------------------
# classify_document_task (integration-style with mocked DB and Celery)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassifyDocumentTask:
"""Test the Celery classify_document_task."""
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_invoice_file(self, mock_session_local, mock_load_rules, mock_log):
"""Should classify a file with invoice filename and text as 'invoice'."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 1
mock_file.original_filename = "Invoice_2024.pdf"
mock_file.ocr_text = "Invoice number: 12345. Amount due: $500."
mock_file.ai_metadata = None
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
# Call the underlying function directly via .run(), bypassing Celery
result = classify_document_task.run(1, owner_id="test-user")
assert result["status"] == "success"
assert result["category"] == "invoice"
assert result["confidence"] > 0
# Verify ai_metadata was updated
assert mock_file.ai_metadata is not None
metadata = json.loads(mock_file.ai_metadata)
assert "classification" in metadata
assert metadata["classification"]["category"] == "invoice"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_file_not_found(self, mock_session_local, mock_log):
"""Should return error when file record is not found."""
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
result = classify_document_task.run(99999)
assert result["status"] == "error"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_preserves_existing_metadata(self, mock_session_local, mock_load_rules, mock_log):
"""Should preserve existing ai_metadata fields and add classification."""
existing_meta = json.dumps({"document_type": "Invoice", "tags": ["finance"]})
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 2
mock_file.original_filename = "doc.pdf"
mock_file.ocr_text = ""
mock_file.ai_metadata = existing_meta
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
classify_document_task.run(2)
# Check that existing fields are preserved
metadata = json.loads(mock_file.ai_metadata)
assert metadata["tags"] == ["finance"]
assert metadata["document_type"] == "Invoice"
assert "classification" in metadata
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_sets_document_type_when_missing(self, mock_session_local, mock_load_rules, mock_log):
"""Should set document_type from classification when not already present."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 3
mock_file.original_filename = "Invoice_2024.pdf"
mock_file.ocr_text = "Invoice number: 12345"
mock_file.ai_metadata = json.dumps({"tags": ["test"]})
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
classify_document_task.run(3)
metadata = json.loads(mock_file.ai_metadata)
assert metadata["document_type"] == "Invoice"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_unknown_document(self, mock_session_local, mock_load_rules, mock_log):
"""Should classify as 'unknown' when no rules match."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 4
mock_file.original_filename = "random_file.pdf"
mock_file.ocr_text = "Lorem ipsum dolor sit amet."
mock_file.ai_metadata = None
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
result = classify_document_task.run(4)
assert result["category"] == "unknown"
assert result["confidence"] == 0
def test_classify_document_task_is_celery_task(self):
"""Task should be registered as a Celery task."""
from app.tasks.classify_document import classify_document_task
assert hasattr(classify_document_task, "apply_async")
assert hasattr(classify_document_task, "delay")
assert callable(classify_document_task)
+525
View File
@@ -0,0 +1,525 @@
"""Tests for the document comments and annotations API."""
import pytest
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
def _create_file(db_session, owner_id="testuser") -> FileRecord:
"""Helper to create a minimal FileRecord for testing."""
f = FileRecord(
owner_id=owner_id,
filehash="abc123",
original_filename="test.pdf",
local_filename="test.pdf",
file_size=1024,
mime_type="application/pdf",
)
db_session.add(f)
db_session.commit()
db_session.refresh(f)
return f
# ---------------------------------------------------------------------------
# Comment tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListComments:
"""Tests for GET /api/files/{file_id}/comments."""
def test_list_comments_empty(self, client, db_session):
f = _create_file(db_session)
resp = client.get(f"/api/files/{f.id}/comments")
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == f.id
assert data["comments"] == []
assert data["total"] == 0
def test_list_comments_file_not_found(self, client):
resp = client.get("/api/files/99999/comments")
assert resp.status_code == 404
def test_list_comments_threaded(self, client, db_session):
f = _create_file(db_session)
# Root comment
c1 = DocumentComment(file_id=f.id, user_id="alice", body="Hello")
db_session.add(c1)
db_session.commit()
db_session.refresh(c1)
# Reply
c2 = DocumentComment(file_id=f.id, user_id="bob", parent_id=c1.id, body="Hi back")
db_session.add(c2)
db_session.commit()
resp = client.get(f"/api/files/{f.id}/comments")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert len(data["comments"]) == 1 # only root
assert len(data["comments"][0]["replies"]) == 1
assert data["comments"][0]["replies"][0]["body"] == "Hi back"
@pytest.mark.unit
class TestCreateComment:
"""Tests for POST /api/files/{file_id}/comments."""
def test_create_comment(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Great document!"},
)
assert resp.status_code == 201
data = resp.json()
assert data["body"] == "Great document!"
assert data["file_id"] == f.id
assert data["parent_id"] is None
def test_create_comment_with_mention(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Hey @alice please review"},
)
assert resp.status_code == 201
data = resp.json()
assert data["mentions"] == ["alice"]
def test_create_comment_with_parent(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="user1", body="root")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "reply", "parent_id": c.id},
)
assert resp.status_code == 201
assert resp.json()["parent_id"] == c.id
def test_create_comment_parent_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "reply", "parent_id": 99999},
)
assert resp.status_code == 404
def test_create_comment_empty_body(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": " "},
)
assert resp.status_code == 422
def test_create_comment_file_not_found(self, client):
resp = client.post(
"/api/files/99999/comments",
json={"body": "test"},
)
assert resp.status_code == 404
def test_create_comment_body_too_long(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "x" * 10_001},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestUpdateComment:
"""Tests for PUT /api/files/{file_id}/comments/{comment_id}."""
def test_update_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="old body")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.put(
f"/api/files/{f.id}/comments/{c.id}",
json={"body": "new body @bob"},
)
assert resp.status_code == 200
data = resp.json()
assert data["body"] == "new body @bob"
assert data["mentions"] == ["bob"]
def test_update_comment_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.put(
f"/api/files/{f.id}/comments/99999",
json={"body": "new"},
)
assert resp.status_code == 404
def test_update_comment_forbidden(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="other_user", body="old")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.put(
f"/api/files/{f.id}/comments/{c.id}",
json={"body": "new"},
)
assert resp.status_code == 403
@pytest.mark.unit
class TestDeleteComment:
"""Tests for DELETE /api/files/{file_id}/comments/{comment_id}."""
def test_delete_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="to delete")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
assert resp.status_code == 204
# Verify deleted
assert db_session.query(DocumentComment).filter(DocumentComment.id == c.id).first() is None
def test_delete_comment_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.delete(f"/api/files/{f.id}/comments/99999")
assert resp.status_code == 404
def test_delete_comment_forbidden(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="other_user", body="mine")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
assert resp.status_code == 403
@pytest.mark.unit
class TestResolveComment:
"""Tests for PATCH /api/files/{file_id}/comments/{comment_id}/resolve."""
def test_resolve_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.patch(
f"/api/files/{f.id}/comments/{c.id}/resolve",
json={"is_resolved": True},
)
assert resp.status_code == 200
assert resp.json()["is_resolved"] is True
def test_unresolve_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue", is_resolved=True)
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.patch(
f"/api/files/{f.id}/comments/{c.id}/resolve",
json={"is_resolved": False},
)
assert resp.status_code == 200
assert resp.json()["is_resolved"] is False
def test_resolve_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.patch(
f"/api/files/{f.id}/comments/99999/resolve",
json={"is_resolved": True},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Annotation tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListAnnotations:
"""Tests for GET /api/files/{file_id}/annotations."""
def test_list_annotations_empty(self, client, db_session):
f = _create_file(db_session)
resp = client.get(f"/api/files/{f.id}/annotations")
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == f.id
assert data["annotations"] == []
assert data["total"] == 0
def test_list_annotations_file_not_found(self, client):
resp = client.get("/api/files/99999/annotations")
assert resp.status_code == 404
@pytest.mark.unit
class TestCreateAnnotation:
"""Tests for POST /api/files/{file_id}/annotations."""
def test_create_annotation(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={
"page": 1,
"x": 100.0,
"y": 200.0,
"content": "Important note",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["page"] == 1
assert data["x"] == 100.0
assert data["y"] == 200.0
assert data["content"] == "Important note"
assert data["annotation_type"] == "note"
def test_create_annotation_with_all_fields(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={
"page": 2,
"x": 50.0,
"y": 100.0,
"width": 200.0,
"height": 30.0,
"content": "Highlighted text",
"annotation_type": "highlight",
"color": "#ffff00",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["annotation_type"] == "highlight"
assert data["color"] == "#ffff00"
assert data["width"] == 200.0
assert data["height"] == 30.0
def test_create_annotation_file_not_found(self, client):
resp = client.post(
"/api/files/99999/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "test"},
)
assert resp.status_code == 404
def test_create_annotation_empty_content(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": " "},
)
assert resp.status_code == 422
def test_create_annotation_invalid_page(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 0, "x": 0, "y": 0, "content": "test"},
)
assert resp.status_code == 422
def test_create_annotation_invalid_type(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "test", "annotation_type": "invalid"},
)
assert resp.status_code == 422
def test_create_annotation_content_too_long(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "x" * 5_001},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestUpdateAnnotation:
"""Tests for PUT /api/files/{file_id}/annotations/{annotation_id}."""
def test_update_annotation(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"content": "updated note", "color": "#00ff00"},
)
assert resp.status_code == 200
data = resp.json()
assert data["content"] == "updated note"
assert data["color"] == "#00ff00"
def test_update_annotation_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.put(
f"/api/files/{f.id}/annotations/99999",
json={"content": "new"},
)
assert resp.status_code == 404
def test_update_annotation_forbidden(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"content": "hijack"},
)
assert resp.status_code == 403
def test_update_annotation_invalid_type(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"annotation_type": "invalid"},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestDeleteAnnotation:
"""Tests for DELETE /api/files/{file_id}/annotations/{annotation_id}."""
def test_delete_annotation(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(
file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="to delete"
)
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
assert resp.status_code == 204
assert db_session.query(DocumentAnnotation).filter(DocumentAnnotation.id == a.id).first() is None
def test_delete_annotation_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.delete(f"/api/files/{f.id}/annotations/99999")
assert resp.status_code == 404
def test_delete_annotation_forbidden(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Mentionable users tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListMentionableUsers:
"""Tests for GET /api/users/mentionable."""
def test_list_mentionable_empty(self, client, db_session):
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
assert resp.json() == []
def test_list_mentionable_users(self, client, db_session):
p1 = UserProfile(user_id="alice", display_name="Alice A")
p2 = UserProfile(user_id="bob", display_name="Bob B")
db_session.add_all([p1, p2])
db_session.commit()
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["user_id"] == "alice"
assert data[1]["user_id"] == "bob"
def test_blocked_users_excluded(self, client, db_session):
p1 = UserProfile(user_id="alice", display_name="Alice A", is_blocked=False)
p2 = UserProfile(user_id="blocked", display_name="Blocked", is_blocked=True)
db_session.add_all([p1, p2])
db_session.commit()
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["user_id"] == "alice"
# ---------------------------------------------------------------------------
# Mention extraction helper tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestExtractMentions:
"""Tests for the _extract_mentions helper function."""
def test_no_mentions(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("Hello world") == []
def test_single_mention(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("Hey @alice check this") == ["alice"]
def test_multiple_mentions(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("@alice @bob @charlie") == ["alice", "bob", "charlie"]
def test_duplicate_mentions(self):
from app.api.comments import _extract_mentions
result = _extract_mentions("@alice and @alice again")
assert result == ["alice"]
def test_mention_with_dots_and_dashes(self):
from app.api.comments import _extract_mentions
result = _extract_mentions("@user.name @user-name")
assert result == ["user.name", "user-name"]
+210
View File
@@ -0,0 +1,210 @@
"""Tests for the comments and annotations UI on the file annotations page."""
import pytest
from fastapi.testclient import TestClient
from app.models import FileRecord
def _create_file(db_session, tmp_path) -> FileRecord:
"""Create a minimal FileRecord with a real file path for the annotations page."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
f = FileRecord(
filehash="uihash123",
original_filename="test.pdf",
local_filename=str(file_path),
original_file_path=str(file_path),
file_size=1024,
mime_type="application/pdf",
)
db_session.add(f)
db_session.commit()
db_session.refresh(f)
return f
@pytest.mark.unit
class TestCommentsUIRendering:
"""Verify the file annotations page includes the comments panel HTML."""
def test_annotations_page_contains_comments_section(self, client: TestClient, db_session, tmp_path):
"""The annotations page should render the comments panel container."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="comments-list"' in html
assert 'id="comment-form"' in html
assert 'id="comment-input"' in html
def test_annotations_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path):
"""The annotations page should render the annotations panel container."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="annotations-list"' in html
assert 'id="annotation-form"' in html
assert 'id="annotation-content-input"' in html
def test_annotations_page_loads_comments_js(self, client: TestClient, db_session, tmp_path):
"""The annotations page should include the comments JavaScript file."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "js/comments.js" in resp.text
def test_annotations_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path):
"""The annotations page should include the annotations JavaScript file."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "js/annotations.js" in resp.text
def test_annotations_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path):
"""The mention autocomplete dropdown should be present."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert 'id="mention-dropdown"' in resp.text
def test_annotations_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path):
"""Annotation form should have page, type, and color inputs."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="annotation-page-input"' in html
assert 'id="annotation-type-input"' in html
assert 'id="annotation-color-input"' in html
def test_annotations_page_has_collab_grid(self, client: TestClient, db_session, tmp_path):
"""Comments and annotations should be in a side-by-side grid layout."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "collab-grid" in resp.text
def test_annotations_page_no_comments_for_missing_file(self, client: TestClient):
"""When file is not found, no comments section should appear."""
resp = client.get("/files/99999/annotations")
assert resp.status_code == 200
# The error block is shown, not the main content
assert 'id="comments-list"' not in resp.text
def test_annotations_page_annotation_type_options(self, client: TestClient, db_session, tmp_path):
"""Annotation type selector should include all four types."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'value="note"' in html
assert 'value="highlight"' in html
assert 'value="underline"' in html
assert 'value="strikethrough"' in html
def test_annotations_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path):
"""Comments panel should have proper ARIA attributes."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'aria-live="polite"' in html
assert 'role="listbox"' in html
def test_annotations_page_init_script(self, client: TestClient, db_session, tmp_path):
"""The init script should call initComments and initAnnotations."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert "initComments" in html
assert "initAnnotations" in html
def test_comments_url_redirects_to_annotations(self, client: TestClient, db_session, tmp_path):
"""The /comments URL should redirect to /annotations."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/comments", follow_redirects=False)
assert resp.status_code == 302
assert f"/files/{f.id}/annotations" in resp.headers["location"]
def test_process_page_no_comments_section(self, client: TestClient, db_session, tmp_path):
"""The process page should NOT render the comments panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/process")
assert resp.status_code == 200
html = resp.text
assert 'id="comments-list"' not in html
assert 'id="comment-form"' not in html
def test_detail_page_no_comments_section(self, client: TestClient, db_session, tmp_path):
"""The detail page should NOT render the comments panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/detail")
assert resp.status_code == 200
html = resp.text
assert 'id="comments-list"' not in html
assert 'id="annotation-form"' not in html
def test_annotations_page_has_embedpdf_viewer_for_pdf(self, client: TestClient, db_session, tmp_path):
"""The annotations page should include the EmbedPDF viewer for PDF files."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="embedpdf-viewer"' in html
assert "@embedpdf/snippet" in html
def test_embedpdf_init_subscribes_to_page_change(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script should subscribe to page change events to sync the form."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
# Verifies the viewer registry is awaited and scroll plugin is used
assert "viewer.registry" in html
assert "onPageChange" in html
assert "annotation-page-input" in html
def test_embedpdf_init_exposes_scroll_function(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script must expose _embedpdfScrollToPage for the annotations panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "_embedpdfScrollToPage" in resp.text
assert "scrollToPage" in resp.text
def test_embedpdf_init_saves_viewer_annotations(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script should capture annotation events and POST to the API."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert "onAnnotationEvent" in html
# Verifies the POST target is the annotations API for this file
assert "/api/files/" in html and "/annotations" in html
def test_embedpdf_init_reloads_annotation_list(self, client: TestClient, db_session, tmp_path):
"""After auto-saving a viewer annotation, the panel list should be refreshed."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "_reloadAnnotations" in resp.text
def test_annotations_page_has_go_to_page_i18n(self, client: TestClient, db_session, tmp_path):
"""The annotations i18n bundle should include the go_to_page key."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "go_to_page" in resp.text
def test_summary_page_renders(self, client: TestClient, db_session, tmp_path):
"""The summary page at /files/{id} should render correctly."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert "Document Detail" in html
assert "Processing" in html
assert "Comments" in html or "Annotations" in html
+494
View File
@@ -0,0 +1,494 @@
"""Tests for the Connections admin page and new authentication providers."""
from unittest.mock import MagicMock, patch
import pytest
from fastapi import status
@pytest.mark.unit
class TestDropboxComplianceFix:
"""Tests for the Dropbox userinfo compliance fix."""
def test_dropbox_compliance_fix_adds_sub(self):
"""Test that compliance fix adds 'sub' from account_id."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"account_id": "dbid:abc123", "email": "test@example.com"}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert result["sub"] == "dbid:abc123"
def test_dropbox_compliance_fix_does_not_overwrite_sub(self):
"""Test that compliance fix preserves existing 'sub'."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"account_id": "dbid:abc123", "sub": "existing-sub"}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert result["sub"] == "existing-sub"
def test_dropbox_compliance_fix_normalizes_name(self):
"""Test that compliance fix normalizes nested name object."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"name": {"display_name": "John Doe", "given_name": "John"}}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert result["name"] == "John Doe"
def test_dropbox_compliance_fix_handles_no_name(self):
"""Test compliance fix works when no name is present."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"email": "test@example.com"}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert "email" in result
@pytest.mark.unit
class TestGitHubNormalization:
"""Tests for GitHub userinfo normalization."""
def test_github_normalize_standard(self):
"""Test GitHub userinfo normalization with standard response."""
from app.auth import _normalize_social_userinfo
raw = {
"id": 12345,
"login": "octocat",
"name": "The Octocat",
"email": "octocat@github.com",
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
}
result = _normalize_social_userinfo("github", {}, raw)
assert result["sub"] == "12345"
assert result["email"] == "octocat@github.com"
assert result["name"] == "The Octocat"
assert result["preferred_username"] == "octocat"
assert result["picture"] == "https://avatars.githubusercontent.com/u/12345"
def test_github_normalize_no_name_uses_login(self):
"""Test GitHub normalization falls back to login when name is empty."""
from app.auth import _normalize_social_userinfo
raw = {"id": 12345, "login": "octocat", "name": "", "email": "octocat@github.com"}
result = _normalize_social_userinfo("github", {}, raw)
assert result["name"] == "octocat"
def test_github_normalize_missing_fields(self):
"""Test GitHub normalization handles missing fields gracefully."""
from app.auth import _normalize_social_userinfo
result = _normalize_social_userinfo("github", {}, {})
assert result["sub"] == ""
assert result["email"] == ""
assert result["name"] == ""
assert result["preferred_username"] == ""
@pytest.mark.unit
class TestSSOAutoLogin:
"""Tests for SSO Auto Login configuration."""
def test_sso_auto_login_default_false(self):
"""Test that SSO auto login defaults to False."""
from app.config import Settings
s = Settings(
_env_file=None,
auth_enabled=True,
)
assert s.sso_auto_login is False
def test_sso_auto_login_can_be_enabled(self):
"""Test that SSO auto login can be set to True."""
from app.config import Settings
s = Settings(
_env_file=None,
auth_enabled=True,
sso_auto_login=True,
)
assert s.sso_auto_login is True
@pytest.mark.unit
class TestNewConfigFields:
"""Tests for new configuration fields."""
def test_github_config_defaults(self):
"""Test GitHub social auth config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_github_enabled is False
assert s.social_auth_github_client_id is None
assert s.social_auth_github_client_secret is None
def test_keycloak_config_defaults(self):
"""Test Keycloak social auth config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_keycloak_enabled is False
assert s.social_auth_keycloak_client_id is None
assert s.social_auth_keycloak_server_url is None
assert s.social_auth_keycloak_realm is None
def test_generic_oauth2_config_defaults(self):
"""Test Generic OAuth2 config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_generic_oauth2_enabled is False
assert s.social_auth_generic_oauth2_scope == "openid profile email"
assert s.social_auth_generic_oauth2_name == "OAuth2"
def test_saml2_config_defaults(self):
"""Test SAML2 config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_saml2_enabled is False
assert s.social_auth_saml2_name == "SAML2"
def test_telegram_config_defaults(self):
"""Test Telegram config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.telegram_enabled is False
assert s.telegram_bot_token is None
assert s.telegram_chat_id is None
@pytest.mark.unit
class TestSettingsMetadata:
"""Tests that new settings have metadata entries."""
def test_github_settings_have_metadata(self):
"""Test GitHub settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_github_enabled" in SETTING_METADATA
assert "social_auth_github_client_id" in SETTING_METADATA
assert "social_auth_github_client_secret" in SETTING_METADATA
def test_keycloak_settings_have_metadata(self):
"""Test Keycloak settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_keycloak_enabled" in SETTING_METADATA
assert "social_auth_keycloak_client_id" in SETTING_METADATA
assert "social_auth_keycloak_server_url" in SETTING_METADATA
assert "social_auth_keycloak_realm" in SETTING_METADATA
def test_generic_oauth2_settings_have_metadata(self):
"""Test Generic OAuth2 settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_generic_oauth2_enabled" in SETTING_METADATA
assert "social_auth_generic_oauth2_authorize_url" in SETTING_METADATA
assert "social_auth_generic_oauth2_token_url" in SETTING_METADATA
def test_saml2_settings_have_metadata(self):
"""Test SAML2 settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_saml2_enabled" in SETTING_METADATA
assert "social_auth_saml2_sso_url" in SETTING_METADATA
assert "social_auth_saml2_entity_id" in SETTING_METADATA
def test_telegram_settings_have_metadata(self):
"""Test Telegram settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "telegram_enabled" in SETTING_METADATA
assert "telegram_bot_token" in SETTING_METADATA
assert "telegram_chat_id" in SETTING_METADATA
def test_sso_auto_login_has_metadata(self):
"""Test SSO auto login has metadata."""
from app.utils.settings_service import SETTING_METADATA
assert "sso_auto_login" in SETTING_METADATA
meta = SETTING_METADATA["sso_auto_login"]
assert meta["category"] == "Authentication"
assert meta["type"] == "boolean"
def test_github_category_is_social_login(self):
"""Test GitHub settings are in Social Login category."""
from app.utils.settings_service import SETTING_METADATA
assert SETTING_METADATA["social_auth_github_enabled"]["category"] == "Social Login"
def test_github_secret_is_sensitive(self):
"""Test GitHub client secret is marked sensitive."""
from app.utils.settings_service import SETTING_METADATA
assert SETTING_METADATA["social_auth_github_client_secret"]["sensitive"] is True
def test_github_has_help_link(self):
"""Test GitHub has a help link to developer settings."""
from app.utils.settings_service import SETTING_METADATA
assert "help_link" in SETTING_METADATA["social_auth_github_enabled"]
@pytest.mark.unit
class TestConnectionsPageRoute:
"""Tests for the /admin/connections route."""
@pytest.mark.asyncio
async def test_connections_page_non_admin_redirected(self):
"""Test that non-admin users are redirected from connections page."""
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": False}}
mock_db = MagicMock()
# The require_admin_access decorator should handle this, so we test the decorator
from app.views.settings import require_admin_access
@require_admin_access
async def dummy_view(request):
return "success"
result = await dummy_view(mock_request)
assert result.status_code == status.HTTP_302_FOUND
@pytest.mark.asyncio
async def test_connections_page_returns_services(self):
"""Test that connections page includes expected services in context."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
with (
patch("app.views.settings.get_all_settings_from_db", return_value={}),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
result = await connections_page(mock_request, db=mock_db)
# Check TemplateResponse was called
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
template_name = call_args[0][0]
context = call_args[0][1]
assert template_name == "admin_connections.html"
assert "services" in context
assert "service_settings" in context
assert "sso_auto_login" in context
# Verify expected service keys
service_keys = [s["key"] for s in context["services"]]
assert "google" in service_keys
assert "github" in service_keys
assert "keycloak" in service_keys
assert "generic_oauth2" in service_keys
assert "saml2" in service_keys
assert "smtp" in service_keys
assert "telegram" in service_keys
@pytest.mark.asyncio
async def test_connections_page_linked_status_from_db(self):
"""Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet)
db_values = {
"social_auth_github_enabled": "true",
"social_auth_github_client_id": "gh-id",
"social_auth_github_client_secret": "gh-secret",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
# GitHub should be linked because DB values say so
assert services_by_key["github"]["linked"] is True
@pytest.mark.asyncio
async def test_connections_page_unlinked_when_credentials_missing(self):
"""Provider is unlinked when enabled=true but credentials are absent."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# enabled but no credentials
db_values = {"social_auth_github_enabled": "true"}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["github"]["linked"] is False
@pytest.mark.asyncio
async def test_connections_page_oidc_linked_from_db(self):
"""OIDC linked status derives from DB effective settings."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
db_values = {
"authentik_client_id": "my-client-id",
"authentik_client_secret": "my-secret",
"oauth_provider_name": "My SSO",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["oidc"]["linked"] is True
assert services_by_key["oidc"]["name"] == "My SSO"
# oauth_configured template var should also reflect the DB state
assert context["oauth_configured"] is True
@pytest.mark.unit
class TestRefreshSocialProviders:
"""Tests for the refresh_social_providers() mechanism."""
def test_refresh_social_providers_exists(self):
"""refresh_social_providers is importable from app.auth."""
from app.auth import refresh_social_providers
assert callable(refresh_social_providers)
def test_refresh_social_providers_clears_and_repopulates(self):
"""After refresh, SOCIAL_PROVIDERS reflects current settings."""
import app.auth as auth_module
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.social_auth_google_enabled = True
mock_settings.social_auth_google_client_id = "gid"
mock_settings.social_auth_google_client_secret = "gsecret"
mock_settings.social_auth_google_use_global_credentials = False
# All other providers disabled
for attr in (
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "google" in auth_module.SOCIAL_PROVIDERS
assert auth_module.OAUTH_CONFIGURED is False
def test_refresh_clears_previous_providers(self):
"""Providers removed from settings are cleared after refresh."""
import app.auth as auth_module
# Pre-populate with a stale entry
auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""}
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
for attr in (
"social_auth_google_enabled",
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS
def test_register_oauth_client_clears_cache(self):
"""_register_oauth_client removes the cached client before re-registering."""
import app.auth as auth_module
# Inject a fake cached client
auth_module.oauth._clients["test_provider"] = object()
with patch.object(auth_module.oauth, "register"):
auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y")
assert "test_provider" not in auth_module.oauth._clients
@pytest.mark.unit
class TestTranslationKeys:
"""Tests for new translation keys."""
def test_connections_translation_keys_exist(self):
"""Test that connections translation keys are in en.json."""
import json
from pathlib import Path
en_path = Path(__file__).parents[1] / "frontend" / "translations" / "en.json"
translations = json.loads(en_path.read_text())
expected_keys = [
"connections.title",
"connections.description",
"connections.configure",
"connections.linked",
"connections.unlinked",
"connections.sso_auto_login",
"connections.sso_auto_login_title",
"connections.sso_auto_login_description",
"connections.mobile_upload_title",
"connections.qr_code_enabled",
"connections.unlinked_services",
"nav.connections",
]
for key in expected_keys:
assert key in translations, f"Missing translation key: {key}"

Some files were not shown because too many files have changed in this diff Show More