c7d3ec57c3
Commitd2217531(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
326 lines
10 KiB
Python
326 lines
10 KiB
Python
"""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)
|