diff --git a/.env.demo b/.env.demo index d94bdb0e..4299371d 100644 --- a/.env.demo +++ b/.env.demo @@ -457,6 +457,15 @@ ONEDRIVE_TENANT_ID=common ONEDRIVE_REFRESH_TOKEN=your-refresh-token ONEDRIVE_FOLDER_PATH=Documents/Uploads +# SharePoint +SHAREPOINT_CLIENT_ID=your-client-id +SHAREPOINT_CLIENT_SECRET=your-client-secret +SHAREPOINT_TENANT_ID=common +SHAREPOINT_REFRESH_TOKEN=your-refresh-token +SHAREPOINT_SITE_URL=https://tenant.sharepoint.com/sites/sitename +SHAREPOINT_DOCUMENT_LIBRARY=Documents +SHAREPOINT_FOLDER_PATH=Uploads + # WebDAV # WEBDAV_ENABLED=true # Set to false to disable WebDAV uploads without removing credentials WEBDAV_URL=https://webdav.example.com/path diff --git a/BUILD_DATE b/BUILD_DATE index 81deb728..1fcd30d4 100644 --- a/BUILD_DATE +++ b/BUILD_DATE @@ -1 +1 @@ -2026-03-17T11:23:05Z +2026-03-19T10:12:45Z diff --git a/CHANGELOG.md b/CHANGELOG.md index 19be28ad..e0e61c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## v0.157.1 (2026-03-19) + +### Bug Fixes + +- **mobile**: Resolve iOS "unmatched route docuelevate://" error in Open In share flow + ([`f2b7db8`](https://github.com/christianlouis/DocuElevate/commit/f2b7db88ba86e894a9bf2e4e7d6a54cd7f423194)) + + +## v0.157.0 (2026-03-19) + +### Features + +- **api**: Allow disabled tokens/devices to be deleted & reactivated; add token lifetime + ([`e4749b4`](https://github.com/christianlouis/DocuElevate/commit/e4749b4e7cdd78ab95627736d3fcd84fecc55e53)) + + +## v0.156.3 (2026-03-18) + +### Bug Fixes + +- **auth**: Exempt /api/qr-auth/claim from CSRF to fix mobile QR login + ([`a4aaebf`](https://github.com/christianlouis/DocuElevate/commit/a4aaebfe6649ef051790b6af24e0739bdd9ecfed)) + +### Documentation + +- **changelog**: Update changelog [skip ci] + ([`d8d2016`](https://github.com/christianlouis/DocuElevate/commit/d8d2016f8597f775cdf3848e7baeaae98cb998b0)) + + +## Unreleased + + +## v0.156.2 (2026-03-18) + +### Bug Fixes + +- **qr-login**: Render QR code server-side using segno instead of CDN JS library + ([`a8eb650`](https://github.com/christianlouis/DocuElevate/commit/a8eb6504ac100c14b10f57511fde8f1202deeffe)) + +### Chores + +- Initial plan for server-side QR code rendering + ([`6727253`](https://github.com/christianlouis/DocuElevate/commit/6727253958a6ae8436fc1184736ec43eef2ab820)) + +- Remove accidentally committed =1.6.0 file + ([`ba8c88b`](https://github.com/christianlouis/DocuElevate/commit/ba8c88bc17d3245cba6079ac5ba3be5fddf36366)) + + +## v0.156.1 (2026-03-18) + +### Bug Fixes + +- Add missing SETTING_METADATA entries for db pool and upload rate limit settings + ([`dc0a19b`](https://github.com/christianlouis/DocuElevate/commit/dc0a19bd118d3503ad50608f55fb0bc4ce104948)) + + +## v0.156.0 (2026-03-17) + +### Bug Fixes + +- **storage**: Use RuntimeError instead of bare Exception in SharePoint task + ([`2b698cc`](https://github.com/christianlouis/DocuElevate/commit/2b698cc6940fb731b1ab87300ad0f7fdebc8f024)) + +- **test**: Add missing _should_upload_to_sharepoint mock to send_to_all tests + ([`bcf2d00`](https://github.com/christianlouis/DocuElevate/commit/bcf2d00c3324a3ded3852e16759ea4d0af3af666)) + +### Documentation + +- Add SharePoint setup guide and update all references + ([`13aa14b`](https://github.com/christianlouis/DocuElevate/commit/13aa14b8e4102437f72f6c260b2795a6ee761eb9)) + +### Features + +- **storage**: Add SharePoint integration for document storage + ([`b85fc1d`](https://github.com/christianlouis/DocuElevate/commit/b85fc1d277475c06c1efa100c391b7a3c43e7c25)) + + +## v0.155.1 (2026-03-17) + +### Bug Fixes + +- **api**: Add ttl_seconds to QR challenge response and fix client-side countdown + ([`0f6a1ee`](https://github.com/christianlouis/DocuElevate/commit/0f6a1ee1ec8186d70afc17abe50258c968058c92)) + +- **mobile**: Replace gap with marginLeft for React Native compatibility + ([`70b193e`](https://github.com/christianlouis/DocuElevate/commit/70b193e07d2f85e353b6b12c56bf5bcbf828ee24)) + +### Documentation + +- Update QR code login documentation with scanner and TTL details + ([`723b14e`](https://github.com/christianlouis/DocuElevate/commit/723b14e660737887c454b8e8300ac38bb390841f)) + + ## v0.155.0 (2026-03-17) diff --git a/GIT_SHA b/GIT_SHA index 78a6cd29..9b5fb649 100644 --- a/GIT_SHA +++ b/GIT_SHA @@ -1 +1 @@ -30c2e9a +786c909 diff --git a/RUNTIME_INFO b/RUNTIME_INFO index b9756ce3..cc9eea84 100644 --- a/RUNTIME_INFO +++ b/RUNTIME_INFO @@ -1,10 +1,10 @@ DocuElevate Build Information ============================== -Version: 0.155.0 -Build Date: 2026-03-17T11:23:05Z -Git Commit: 30c2e9afefc57c8d1e19548afec7475f5a838f24 -Git Short SHA: 30c2e9a +Version: 0.157.1 +Build Date: 2026-03-19T10:12:45Z +Git Commit: 786c909765e980665b69851f00b92f19a693bfd4 +Git Short SHA: 786c909 Git Branch: main -Commit Date: 2026-03-17T12:22:07+01:00 -Build Timestamp: 2026-03-17T11:23:05Z +Commit Date: 2026-03-19T11:12:21+01:00 +Build Timestamp: 2026-03-19T10:12:45Z ============================== diff --git a/VERSION b/VERSION index 03ed6e33..4f58d488 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.155.0 +0.157.1 diff --git a/app/api/api_tokens.py b/app/api/api_tokens.py index 1beef61b..62074b8a 100644 --- a/app/api/api_tokens.py +++ b/app/api/api_tokens.py @@ -13,7 +13,7 @@ plaintext is returned exactly once at creation time. import hashlib import logging import secrets -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -105,6 +105,7 @@ def _token_to_dict(t: ApiToken) -> dict[str, Any]: "last_used_ip": t.last_used_ip, "created_at": t.created_at, "revoked_at": t.revoked_at, + "expires_at": t.expires_at, } @@ -117,6 +118,12 @@ class TokenCreate(BaseModel): """Schema for creating a new API token.""" name: str = Field(..., min_length=1, max_length=255, description="Human-readable label for the token") + expires_in_days: int | None = Field( + default=None, + ge=1, + le=3650, # Maximum 10 years; keeps tokens from being effectively permanent while allowing long-lived CI/CD tokens. + description="Optional lifetime in days. If omitted the token never expires.", + ) class TokenResponse(BaseModel): @@ -130,6 +137,7 @@ class TokenResponse(BaseModel): last_used_ip: str | None created_at: datetime | None revoked_at: datetime | None + expires_at: datetime | None model_config = {"from_attributes": True} @@ -160,11 +168,16 @@ async def create_token( token_hash_value = hash_token(plaintext) prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total + expires_at = None + if body.expires_in_days is not None: + expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days) + db_token = ApiToken( owner_id=owner_id, name=body.name, token_hash=token_hash_value, token_prefix=prefix, + expires_at=expires_at, ) try: db.add(db_token) @@ -185,6 +198,7 @@ async def create_token( "last_used_ip": db_token.last_used_ip, "created_at": db_token.created_at, "revoked_at": db_token.revoked_at, + "expires_at": db_token.expires_at, "token": plaintext, } @@ -235,30 +249,73 @@ async def list_mobile_tokens( @router.delete("/{token_id}", status_code=status.HTTP_200_OK) -async def revoke_token( +async def revoke_or_delete_token( token_id: int, owner_id: CurrentOwner, db: DbSession, ) -> dict[str, str]: - """Revoke (soft-delete) an API token. + """Revoke or permanently delete an API token. - The token row is kept for audit purposes but marked inactive with a - ``revoked_at`` timestamp. + * **Active token** – soft-revoked: the row is kept for audit purposes + but marked inactive with a ``revoked_at`` timestamp. + * **Already-revoked token** – hard-deleted: the row is permanently + removed from the database. """ db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first() if not db_token: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found") - if not db_token.is_active: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already revoked") + if db_token.is_active: + # Soft-revoke the active token. + try: + db_token.is_active = False + db_token.revoked_at = datetime.now(timezone.utc) + db.commit() + except Exception: + db.rollback() + raise + logger.info("API token revoked: id=%s owner=%s", token_id, owner_id) + return {"detail": "Token revoked"} + # Hard-delete an already-revoked token. try: - db_token.is_active = False - db_token.revoked_at = datetime.now(timezone.utc) + db.delete(db_token) db.commit() except Exception: db.rollback() raise + logger.info("API token permanently deleted: id=%s owner=%s", token_id, owner_id) + return {"detail": "Token deleted"} - logger.info("API token revoked: id=%s owner=%s", token_id, owner_id) - return {"detail": "Token revoked"} + +@router.post("/{token_id}/reactivate", status_code=status.HTTP_200_OK, response_model=TokenResponse) +async def reactivate_token( + token_id: int, + owner_id: CurrentOwner, + db: DbSession, +) -> dict[str, Any]: + """Reactivate a previously revoked API token. + + Clears the ``revoked_at`` timestamp and sets ``is_active`` back to + ``True``. The token can be used for authentication again immediately. + If the token had an ``expires_at`` in the past the caller should + consider re-creating a new token instead. + """ + db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first() + if not db_token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found") + + if db_token.is_active: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already active") + + try: + db_token.is_active = True + db_token.revoked_at = None + db.commit() + db.refresh(db_token) + except Exception: + db.rollback() + raise + + logger.info("API token reactivated: id=%s owner=%s", token_id, owner_id) + return _token_to_dict(db_token) diff --git a/app/api/files.py b/app/api/files.py index 58937c30..15ea6765 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -347,7 +347,9 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession): try: # Find all file records - file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all() + query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)) + query = apply_owner_filter(query, request) + file_records = query.all() if not file_records: raise HTTPException(status_code=404, detail="No files found with the provided IDs") @@ -386,7 +388,9 @@ def bulk_reprocess_files(request: Request, file_ids: List[int], db: DbSession): """ try: # Find all file records - file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all() + query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)) + query = apply_owner_filter(query, request) + file_records = query.all() if not file_records: raise HTTPException(status_code=404, detail="No files found with the provided IDs") @@ -458,7 +462,9 @@ def bulk_reprocess_files_cloud_ocr(request: Request, file_ids: List[int], db: Db Useful for re-running OCR on files with poor text quality or missing OCR text. """ try: - file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all() + query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)) + query = apply_owner_filter(query, request) + file_records = query.all() if not file_records: raise HTTPException(status_code=404, detail="No files found with the provided IDs") @@ -538,7 +544,9 @@ def bulk_download_files(request: Request, file_ids: List[int], db: DbSession): Files not found on disk are silently skipped. """ try: - file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all() + query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)) + query = apply_owner_filter(query, request) + file_records = query.all() if not file_records: raise HTTPException(status_code=404, detail="No files found with the provided IDs") @@ -620,7 +628,9 @@ def reprocess_single_file(request: Request, file_id: int, db: DbSession): """ try: # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + query = db.query(FileRecord).filter(FileRecord.id == file_id) + query = apply_owner_filter(query, request) + file_record = query.first() if not file_record: raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found") @@ -676,7 +686,9 @@ def reprocess_with_cloud_ocr(request: Request, file_id: int, db: DbSession): """ try: # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + query = db.query(FileRecord).filter(FileRecord.id == file_id) + query = apply_owner_filter(query, request) + file_record = query.first() if not file_record: raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found") @@ -939,7 +951,9 @@ def retry_subtask( """ try: # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + query = db.query(FileRecord).filter(FileRecord.id == file_id) + query = apply_owner_filter(query, request) + file_record = query.first() if not file_record: raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found") @@ -1081,7 +1095,9 @@ def get_file_preview( try: # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + query = db.query(FileRecord).filter(FileRecord.id == file_id) + query = apply_owner_filter(query, request) + file_record = query.first() if not file_record: raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found") @@ -1161,7 +1177,9 @@ def download_file( try: # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + query = db.query(FileRecord).filter(FileRecord.id == file_id) + query = apply_owner_filter(query, request) + file_record = query.first() if not file_record: raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found") diff --git a/app/api/mobile.py b/app/api/mobile.py index 465872ab..c9163f00 100644 --- a/app/api/mobile.py +++ b/app/api/mobile.py @@ -273,31 +273,44 @@ async def list_devices( return [_device_to_response(d) for d in devices] -@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/devices/{device_id}", status_code=status.HTTP_200_OK) @require_login async def deactivate_device( request: Request, device_id: int, owner_id: CurrentOwner, db: DbSession, -) -> None: - """Deactivate a push-notification device registration. +) -> dict[str, str]: + """Deactivate or permanently delete a push-notification device registration. - The device record is kept for audit purposes but will no longer receive - push notifications. + * **Active device** – soft-deactivated: the record is kept for audit + purposes but will no longer receive push notifications. + * **Already-inactive device** – hard-deleted: the record is permanently + removed from the database. """ device = db.get(MobileDevice, device_id) if not device or device.owner_id != owner_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") - device.is_active = False + if device.is_active: + device.is_active = False + try: + db.commit() + except Exception: + db.rollback() + raise + logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id) + return {"detail": "Device deactivated"} + + # Hard-delete an already-inactive device. try: + db.delete(device) db.commit() except Exception: db.rollback() raise - - logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id) + logger.info("Mobile device permanently deleted: id=%s owner=%s", device_id, owner_id) + return {"detail": "Device deleted"} @router.get("/whoami", response_model=WhoAmIResponse) diff --git a/app/api/qr_auth.py b/app/api/qr_auth.py index b7e1d439..8f891e4e 100644 --- a/app/api/qr_auth.py +++ b/app/api/qr_auth.py @@ -19,10 +19,13 @@ Security properties: from __future__ import annotations +import base64 +import io import logging from datetime import datetime from typing import Annotated, Any +import segno from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -70,7 +73,9 @@ class CreateChallengeResponse(BaseModel): challenge_id: int challenge_token: str expires_at: datetime + ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).") qr_payload: str = Field(description="The string to encode in the QR code.") + qr_code_svg: str = Field(description="Base64-encoded SVG data URI of the QR code, ready for use in an src.") class ChallengeStatusResponse(BaseModel): @@ -105,6 +110,29 @@ class ClaimChallengeResponse(BaseModel): created_at: datetime +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# QR code rendering parameters +_QR_ERROR_LEVEL = "M" # Medium error correction (~15% recovery); sufficient for on-screen display +_QR_SCALE = 4 # Each QR module is rendered as 4×4 SVG pixels + + +def _generate_qr_svg(payload: str) -> str: + """Generate a QR code for *payload* and return it as a base64 SVG data URI. + + Using ``segno`` (pure-Python, no Pillow dependency) and SVG output so the + QR code scales crisply at any resolution without requiring a canvas or any + client-side JavaScript library. + """ + qr = segno.make(payload, error=_QR_ERROR_LEVEL) + buf = io.BytesIO() + qr.save(buf, kind="svg", scale=_QR_SCALE, xmldecl=False, svgclass=None, lineclass=None, omitsize=True) + svg_bytes = buf.getvalue() + return "data:image/svg+xml;base64," + base64.b64encode(svg_bytes).decode("ascii") + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -131,11 +159,18 @@ async def create_challenge( base_url = str(request.base_url).rstrip("/") qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}" + # Compute the TTL in seconds so the client can run a countdown timer + # without comparing absolute timestamps (which breaks when client and + # server clocks are out of sync). + ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds())) + return { "challenge_id": challenge.id, "challenge_token": challenge.challenge_token, "expires_at": challenge.expires_at, + "ttl_seconds": ttl_seconds, "qr_payload": qr_payload, + "qr_code_svg": _generate_qr_svg(qr_payload), } diff --git a/app/auth.py b/app/auth.py index c5883dac..072e173b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -186,6 +186,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, diff --git a/app/celery_worker.py b/app/celery_worker.py index 17110a66..cca7083b 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -52,6 +52,7 @@ from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401 from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401 from app.tasks.upload_to_s3 import upload_to_s3 # noqa: F401 from app.tasks.upload_to_sftp import upload_to_sftp # noqa: F401 +from app.tasks.upload_to_sharepoint import upload_to_sharepoint # noqa: F401 from app.tasks.upload_to_user_integration import upload_to_user_integration # noqa: F401 from app.tasks.upload_to_webdav import upload_to_webdav # noqa: F401 from app.tasks.upload_with_rclone import send_to_all_rclone_destinations, upload_with_rclone # noqa: F401 diff --git a/app/config.py b/app/config.py index b25a6704..09addb15 100644 --- a/app/config.py +++ b/app/config.py @@ -627,6 +627,15 @@ class Settings(BaseSettings): onedrive_refresh_token: Optional[str] = None # Required for personal accounts onedrive_folder_path: Optional[str] = None + # SharePoint settings + sharepoint_client_id: Optional[str] = None + sharepoint_client_secret: Optional[str] = None + sharepoint_tenant_id: Optional[str] = "common" + sharepoint_refresh_token: Optional[str] = None + sharepoint_site_url: Optional[str] = None # e.g. https://tenant.sharepoint.com/sites/sitename + sharepoint_document_library: Optional[str] = "Documents" # Document library name + sharepoint_folder_path: Optional[str] = None # Subfolder inside the library + # AWS S3 settings s3_enabled: bool = Field( default=True, @@ -1124,6 +1133,45 @@ 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. + 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." + ), + ) + 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." + ), + ) + # Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md) # Protects against DoS attacks and API abuse rate_limiting_enabled: bool = Field( diff --git a/app/database.py b/app/database.py index 66e4b007..701e4673 100644 --- a/app/database.py +++ b/app/database.py @@ -10,7 +10,7 @@ from typing import Any from sqlalchemy import create_engine, exc from sqlalchemy.engine.url import make_url from sqlalchemy.orm import Session, declarative_base, sessionmaker -from sqlalchemy.pool import NullPool +from sqlalchemy.pool import NullPool, QueuePool from app.config import settings @@ -38,6 +38,7 @@ if _parsed_url.get_backend_name() == "sqlite": _engine_kwargs["poolclass"] = NullPool else: # PostgreSQL / MySQL — use a bounded QueuePool with configurable limits. + _engine_kwargs["poolclass"] = QueuePool _engine_kwargs.update( { "pool_size": settings.db_pool_size, diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 842958af..f21088ad 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -20,6 +20,9 @@ How it works: Exempt paths (CSRF is not checked even for state-changing methods): - ``/oauth-callback`` – OAuth 2.0 callback; protected by the ``state`` parameter. +- ``/api/qr-auth/claim`` – Called by the unauthenticated mobile app; the + cryptographically-random, single-use challenge token provides equivalent + protection. """ import logging @@ -39,6 +42,10 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"} # their own replay-protection mechanism). CSRF_EXEMPT_PATHS = { "/oauth-callback", + # The mobile app calls this endpoint without a browser session/CSRF token. + # The cryptographically-random, single-use challenge token already provides + # equivalent protection against cross-site request forgery. + "/api/qr-auth/claim", } diff --git a/app/models.py b/app/models.py index fe5b9c41..2bba0446 100644 --- a/app/models.py +++ b/app/models.py @@ -603,6 +603,7 @@ class IntegrationType: EMAIL = "EMAIL" PAPERLESS = "PAPERLESS" RCLONE = "RCLONE" + SHAREPOINT = "SHAREPOINT" ICLOUD = "ICLOUD" ALL = { @@ -620,6 +621,7 @@ class IntegrationType: EMAIL, PAPERLESS, RCLONE, + SHAREPOINT, ICLOUD, } @@ -784,6 +786,9 @@ class ApiToken(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) revoked_at = Column(DateTime(timezone=True), nullable=True) + # Optional expiry: if set, the token is rejected after this timestamp. + expires_at = Column(DateTime(timezone=True), nullable=True) + class SharedLink(Base): """Shareable, time-limited or view-limited document link. diff --git a/app/tasks/send_to_all.py b/app/tasks/send_to_all.py index 214e0225..17f60c84 100644 --- a/app/tasks/send_to_all.py +++ b/app/tasks/send_to_all.py @@ -18,6 +18,7 @@ from app.tasks.upload_to_onedrive import upload_to_onedrive from app.tasks.upload_to_paperless import upload_to_paperless from app.tasks.upload_to_s3 import upload_to_s3 from app.tasks.upload_to_sftp import upload_to_sftp +from app.tasks.upload_to_sharepoint import upload_to_sharepoint from app.tasks.upload_to_webdav import upload_to_webdav from app.utils.config_validator import get_provider_status from app.utils.logging import log_task_progress @@ -121,6 +122,18 @@ def _should_upload_to_icloud(): return bool(getattr(settings, "icloud_enabled", True) and settings.icloud_username and settings.icloud_password) +def _should_upload_to_sharepoint(): + return bool( + settings.sharepoint_client_id + and settings.sharepoint_client_secret + and settings.sharepoint_site_url + and ( + settings.sharepoint_refresh_token + or (settings.sharepoint_tenant_id and settings.sharepoint_tenant_id != "common") + ) + ) + + def get_configured_services_from_validator(): """ Use the config validator to determine which services are configured and enabled. @@ -140,6 +153,7 @@ def get_configured_services_from_validator(): "Email": "email", "OneDrive": "onedrive", "S3 Storage": "s3", + "SharePoint": "sharepoint", "iCloud Drive": "icloud", } @@ -250,6 +264,11 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id: "should_upload": _should_upload_to_s3, "upload_func": upload_to_s3, }, + { + "name": "sharepoint", + "should_upload": _should_upload_to_sharepoint, + "upload_func": upload_to_sharepoint, + }, { "name": "icloud", "should_upload": _should_upload_to_icloud, diff --git a/app/tasks/upload_to_sharepoint.py b/app/tasks/upload_to_sharepoint.py new file mode 100644 index 00000000..0e2593c1 --- /dev/null +++ b/app/tasks/upload_to_sharepoint.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Upload documents to Microsoft SharePoint via the Microsoft Graph API. + +This module authenticates using MSAL (same OAuth2 flow as OneDrive) and +uploads files to a configurable SharePoint Online document library using +the chunked upload session approach for reliability with large files. + +Key differences from the OneDrive provider: +- Uses ``/sites/{siteId}/drives/{driveId}`` instead of ``/me/drive`` +- Requires a SharePoint site URL to resolve the site and drive IDs +- Targets a named document library (default: ``Documents``) +""" + +import logging +import os +import time +import urllib.parse + +import msal +import requests + +from app.celery_app import celery +from app.config import settings +from app.tasks.retry_config import UploadTaskWithRetry +from app.utils import log_task_progress + +logger = logging.getLogger(__name__) + + +def get_sharepoint_token() -> str: + """Acquire a Microsoft Graph API access token for SharePoint. + + Uses MSAL ``ConfidentialClientApplication`` with the refresh-token flow + (delegated permissions) or the client-credentials flow (application + permissions) depending on configuration. + + Returns: + A valid access token string. + + Raises: + ValueError: When required settings are missing or token acquisition fails. + """ + if not settings.sharepoint_client_id or not settings.sharepoint_client_secret: + raise ValueError("SharePoint client ID and client secret must be configured") + + tenant = settings.sharepoint_tenant_id or "common" + logger.info("Using SharePoint tenant: %s", tenant) + + scopes = ["https://graph.microsoft.com/.default"] + + if settings.sharepoint_refresh_token: + app = msal.ConfidentialClientApplication( + client_id=settings.sharepoint_client_id, + client_credential=settings.sharepoint_client_secret, + authority=f"https://login.microsoftonline.com/{tenant}", + ) + + logger.info("Attempting to acquire SharePoint token using refresh token") + token_response = app.acquire_token_by_refresh_token( + refresh_token=settings.sharepoint_refresh_token, scopes=scopes + ) + + if "access_token" not in token_response: + error = token_response.get("error", "") + error_desc = token_response.get("error_description", "Unknown error") + logger.error("Failed to get SharePoint access token: %s - %s", error, error_desc) + raise ValueError(f"Failed to get SharePoint access token: {error} - {error_desc}") + + if "refresh_token" in token_response: + settings.sharepoint_refresh_token = token_response["refresh_token"] + logger.info("Updated SharePoint refresh token in memory") + + return token_response["access_token"] + + elif settings.sharepoint_tenant_id and settings.sharepoint_tenant_id != "common": + authority = f"https://login.microsoftonline.com/{settings.sharepoint_tenant_id}" + app = msal.ConfidentialClientApplication( + client_id=settings.sharepoint_client_id, + client_credential=settings.sharepoint_client_secret, + authority=authority, + ) + + token_response = app.acquire_token_for_client(scopes=scopes) + + if "access_token" not in token_response: + error = token_response.get("error", "") + error_desc = token_response.get("error_description", "Unknown error") + raise ValueError(f"Failed to get SharePoint access token: {error} - {error_desc}") + + return token_response["access_token"] + + else: + raise ValueError("For SharePoint, either a refresh token or a non-'common' tenant ID is required") + + +def resolve_sharepoint_drive(access_token: str, site_url: str, library_name: str) -> tuple[str, str]: + """Resolve the Graph API site ID and drive ID for a SharePoint site. + + Args: + access_token: Valid Microsoft Graph API token. + site_url: Full SharePoint site URL, e.g. + ``https://tenant.sharepoint.com/sites/sitename``. + library_name: Display name of the document library (e.g. ``Documents``). + + Returns: + A ``(site_id, drive_id)`` tuple. + + Raises: + ValueError: When the site URL cannot be parsed. + RuntimeError: When the Graph API call fails. + """ + parsed = urllib.parse.urlparse(site_url) + hostname = parsed.hostname + site_path = parsed.path.rstrip("/") + + if not hostname or not site_path: + raise ValueError( + f"Invalid SharePoint site URL '{site_url}'. Expected format: https://tenant.sharepoint.com/sites/sitename" + ) + + headers = {"Authorization": f"Bearer {access_token}"} + + # Resolve site ID + site_api_url = f"https://graph.microsoft.com/v1.0/sites/{hostname}:{site_path}" + logger.info("Resolving SharePoint site: %s", site_api_url) + resp = requests.get(site_api_url, headers=headers, timeout=settings.http_request_timeout) + + if resp.status_code != 200: + raise RuntimeError(f"Failed to resolve SharePoint site: {resp.status_code} - {resp.text}") + + site_id = resp.json()["id"] + logger.info("Resolved SharePoint site ID: %s", site_id) + + # Resolve drive ID from the document library name + drives_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives" + resp = requests.get(drives_url, headers=headers, timeout=settings.http_request_timeout) + + if resp.status_code != 200: + raise RuntimeError(f"Failed to list SharePoint drives: {resp.status_code} - {resp.text}") + + drives = resp.json().get("value", []) + drive_id = None + for drive in drives: + if drive.get("name", "").lower() == library_name.lower(): + drive_id = drive["id"] + break + + if not drive_id: + available = [d.get("name") for d in drives] + raise RuntimeError(f"Document library '{library_name}' not found on site. Available libraries: {available}") + + logger.info("Resolved SharePoint drive ID: %s (library: %s)", drive_id, library_name) + return site_id, drive_id + + +def create_sharepoint_upload_session( + filename: str, folder_path: str | None, drive_id: str, site_id: str, access_token: str +) -> str: + """Create a resumable upload session on a SharePoint document library. + + Args: + filename: Name of the file to upload. + folder_path: Optional subfolder path inside the library. + drive_id: Graph API drive ID of the document library. + site_id: Graph API site ID. + access_token: Valid access token. + + Returns: + The upload session URL for chunked PUT requests. + + Raises: + RuntimeError: When session creation fails. + """ + base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}" + + if folder_path: + folder_path = folder_path.strip("/") + path_components = folder_path.split("/") + encoded_path = "/".join(urllib.parse.quote(component) for component in path_components) + encoded_filename = urllib.parse.quote(filename) + item_path = f"/root:/{encoded_path}/{encoded_filename}:/createUploadSession" + else: + encoded_filename = urllib.parse.quote(filename) + item_path = f"/root:/{encoded_filename}:/createUploadSession" + + url = f"{base_url}{item_path}" + request_body = {"item": {"@microsoft.graph.conflictBehavior": "replace"}} + headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} + + logger.info("Creating SharePoint upload session for %s at path %s", filename, folder_path) + response = requests.post(url, headers=headers, json=request_body, timeout=settings.http_request_timeout) + + if response.status_code == 200: + upload_url = response.json().get("uploadUrl") + logger.info("SharePoint upload session created for %s", filename) + return upload_url + else: + raise RuntimeError(f"Failed to create SharePoint upload session: {response.status_code} - {response.text}") + + +def upload_large_file_sharepoint(file_path: str, upload_url: str) -> dict: + """Upload a file to SharePoint using a chunked upload session. + + Args: + file_path: Local path to the file. + upload_url: The upload session URL from ``create_sharepoint_upload_session``. + + Returns: + The Graph API response dict containing file metadata. + + Raises: + RuntimeError: When a chunk upload fails after retries. + """ + file_size = os.path.getsize(file_path) + chunk_size = 10 * 1024 * 1024 # 10 MB + + response = None + with open(file_path, "rb") as f: + chunk_number = 0 + while True: + chunk = f.read(chunk_size) + if not chunk: + break + + chunk_start = chunk_number * chunk_size + chunk_end = chunk_start + len(chunk) - 1 + content_range = f"bytes {chunk_start}-{chunk_end}/{file_size}" + + headers = {"Content-Length": str(len(chunk)), "Content-Range": content_range} + + max_retries = 3 + retry_delay = 2 + + for attempt in range(max_retries): + try: + response = requests.put( + upload_url, headers=headers, data=chunk, timeout=settings.http_request_timeout + ) + if response.status_code in (201, 202): + break + else: + logger.warning( + "SharePoint chunk upload failed (attempt %d): %d", attempt + 1, response.status_code + ) + if attempt < max_retries - 1: + time.sleep(retry_delay * (attempt + 1)) + except Exception as e: + logger.warning("SharePoint chunk upload error (attempt %d): %s", attempt + 1, str(e)) + if attempt < max_retries - 1: + time.sleep(retry_delay * (attempt + 1)) + + if response is None or response.status_code not in (201, 202): + status = response.status_code if response else "no response" + text = response.text if response else "" + raise RuntimeError(f"Failed to upload chunk after {max_retries} attempts: {status} - {text}") + + chunk_number += 1 + + return response.json() if response else {} + + +@celery.task(base=UploadTaskWithRetry, bind=True) +def upload_to_sharepoint(self, file_path: str, file_id: int = None, folder_override: str = None): + """Upload a file to SharePoint Online. + + Args: + file_path: Path to the file to upload. + file_id: Optional file ID to associate with logs. + folder_override: Optional folder path override. + + Returns: + A dict with upload status and file details. + + Raises: + FileNotFoundError: When the file does not exist. + ValueError: When SharePoint is not configured. + RuntimeError: When the upload fails. + """ + task_id = self.request.id + logger.info("[%s] Starting SharePoint upload: %s", task_id, file_path) + log_task_progress( + task_id, + "upload_to_sharepoint", + "in_progress", + f"Uploading to SharePoint: {os.path.basename(file_path)}", + file_id=file_id, + ) + + if not os.path.exists(file_path): + error_msg = f"File not found: {file_path}" + logger.error("[%s] %s", task_id, error_msg) + log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id) + raise FileNotFoundError(error_msg) + + filename = os.path.basename(file_path) + + if not settings.sharepoint_client_id: + error_msg = "SharePoint client ID is not configured" + logger.error("[%s] %s", task_id, error_msg) + log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id) + raise ValueError(error_msg) + + if not settings.sharepoint_site_url: + error_msg = "SharePoint site URL is not configured" + logger.error("[%s] %s", task_id, error_msg) + log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id) + raise ValueError(error_msg) + + try: + access_token = get_sharepoint_token() + + library_name = settings.sharepoint_document_library or "Documents" + site_id, drive_id = resolve_sharepoint_drive(access_token, settings.sharepoint_site_url, library_name) + + folder_path = folder_override if folder_override is not None else settings.sharepoint_folder_path + + upload_url = create_sharepoint_upload_session(filename, folder_path, drive_id, site_id, access_token) + result = upload_large_file_sharepoint(file_path, upload_url) + + web_url = result.get("webUrl", "Not available") + logger.info("[%s] Successfully uploaded %s to SharePoint", task_id, filename) + logger.info("[%s] File accessible at: %s", task_id, web_url) + log_task_progress( + task_id, "upload_to_sharepoint", "success", f"Uploaded to SharePoint: {filename}", file_id=file_id + ) + + return { + "status": "Completed", + "file_path": file_path, + "sharepoint_path": f"{folder_path or ''}/{filename}", + "web_url": web_url, + } + + except Exception as e: + error_msg = f"Failed to upload {filename} to SharePoint: {str(e)}" + logger.error("[%s] %s", task_id, error_msg) + log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id) + raise RuntimeError(error_msg) from e diff --git a/app/tasks/upload_to_user_integration.py b/app/tasks/upload_to_user_integration.py index db21701d..1b23d321 100644 --- a/app/tasks/upload_to_user_integration.py +++ b/app/tasks/upload_to_user_integration.py @@ -571,6 +571,113 @@ def _upload_rclone(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], t return {"status": "Completed", "rclone_dest": dest} +def _upload_sharepoint(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]: + """Upload *file_path* to SharePoint using per-user MSAL credentials.""" + import urllib.parse + + import msal + import requests as _requests + + client_id = creds.get("client_id") or "" + client_secret = creds.get("client_secret") or "" + refresh_token = creds.get("refresh_token") or "" + tenant = cfg.get("tenant_id") or "common" + site_url = cfg.get("site_url") or "" + library_name = cfg.get("document_library") or "Documents" + folder_path = cfg.get("folder_path") or "" + + if not (client_id and client_secret): + raise ValueError("SharePoint integration is missing client_id or client_secret in credentials") + if not site_url: + raise ValueError("SharePoint integration is missing site_url in config") + + scopes = ["https://graph.microsoft.com/.default"] + msal_app = msal.ConfidentialClientApplication( + client_id=client_id, + client_credential=client_secret, + authority=f"https://login.microsoftonline.com/{tenant}", + ) + + if refresh_token: + token_resp = msal_app.acquire_token_by_refresh_token(refresh_token=refresh_token, scopes=scopes) + else: + token_resp = msal_app.acquire_token_for_client(scopes=scopes) + + if "access_token" not in token_resp: + raise ValueError(f"SharePoint token acquisition failed: {token_resp.get('error_description', 'unknown')}") + + access_token = token_resp["access_token"] + headers = {"Authorization": f"Bearer {access_token}"} + + # Resolve site ID + parsed = urllib.parse.urlparse(site_url) + hostname = parsed.hostname + site_path = parsed.path.rstrip("/") + if not hostname or not site_path: + raise ValueError(f"Invalid SharePoint site URL: {site_url}") + + resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{hostname}:{site_path}", headers=headers, timeout=30) + resp.raise_for_status() + site_id = resp.json()["id"] + + # Resolve drive ID + resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives", headers=headers, timeout=30) + resp.raise_for_status() + drive_id = None + for drive in resp.json().get("value", []): + if drive.get("name", "").lower() == library_name.lower(): + drive_id = drive["id"] + break + if not drive_id: + raise RuntimeError(f"Document library '{library_name}' not found on SharePoint site") + + filename = os.path.basename(file_path) + + # Build upload-session URL + base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}" + if folder_path: + folder_path = folder_path.strip("/") + encoded_path = "/".join(urllib.parse.quote(p) for p in folder_path.split("/")) + encoded_file = urllib.parse.quote(filename) + item_path = f"/root:/{encoded_path}/{encoded_file}:/createUploadSession" + else: + encoded_file = urllib.parse.quote(filename) + item_path = f"/root:/{encoded_file}:/createUploadSession" + + session_url = f"{base_url}{item_path}" + session_headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} + resp = _requests.post( + session_url, + headers=session_headers, + json={"item": {"@microsoft.graph.conflictBehavior": "replace"}}, + timeout=30, + ) + resp.raise_for_status() + upload_url = resp.json()["uploadUrl"] + + file_size = os.path.getsize(file_path) + chunk_size = 10 * 1024 * 1024 + with open(file_path, "rb") as fh: + chunk_num = 0 + while True: + chunk = fh.read(chunk_size) + if not chunk: + break + start = chunk_num * chunk_size + end = start + len(chunk) - 1 + upload_headers = { + "Content-Length": str(len(chunk)), + "Content-Range": f"bytes {start}-{end}/{file_size}", + } + upload_resp = _requests.put(upload_url, headers=upload_headers, data=chunk, timeout=120) + if upload_resp.status_code not in (201, 202): + raise RuntimeError(f"SharePoint chunk upload failed: {upload_resp.status_code}") + chunk_num += 1 + + logger.info("[%s] SharePoint upload complete: %s/%s", task_id, folder_path, filename) + return {"status": "Completed", "sharepoint_folder": folder_path, "filename": filename} + + def _upload_icloud(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]: """Upload *file_path* to iCloud Drive using per-user credentials. @@ -615,6 +722,7 @@ _UPLOAD_HANDLERS = { IntegrationType.PAPERLESS: _upload_paperless, IntegrationType.EMAIL: _upload_email, IntegrationType.RCLONE: _upload_rclone, + IntegrationType.SHAREPOINT: _upload_sharepoint, IntegrationType.ICLOUD: _upload_icloud, } diff --git a/app/utils/config_validator/providers.py b/app/utils/config_validator/providers.py index 9a1e7069..91383438 100644 --- a/app/utils/config_validator/providers.py +++ b/app/utils/config_validator/providers.py @@ -296,6 +296,28 @@ def get_provider_status() -> dict[str, dict[str, object]]: }, } + # Check SharePoint configuration + providers["SharePoint"] = { + "name": "SharePoint", + "icon": "fa-brands fa-microsoft", + "configured": bool( + getattr(settings, "sharepoint_client_id", None) + and getattr(settings, "sharepoint_client_secret", None) + and getattr(settings, "sharepoint_site_url", None) + ), + "enabled": True, + "description": "Store documents in Microsoft SharePoint Online", + "details": { + "client_id": getattr(settings, "sharepoint_client_id", "Not set"), + "client_secret": mask_sensitive_value(getattr(settings, "sharepoint_client_secret", None)), + "tenant_id": getattr(settings, "sharepoint_tenant_id", "Not set"), + "refresh_token": mask_sensitive_value(getattr(settings, "sharepoint_refresh_token", None)), + "site_url": getattr(settings, "sharepoint_site_url", "Not set"), + "document_library": getattr(settings, "sharepoint_document_library", "Not set"), + "folder_path": getattr(settings, "sharepoint_folder_path", "Not set"), + }, + } + # Check S3 configuration providers["S3 Storage"] = { "name": "S3 Storage", diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index d146e0de..6a912bfc 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -943,6 +943,63 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + # Storage Providers - SharePoint + "sharepoint_client_id": { + "category": "Storage Providers", + "description": "SharePoint Azure AD application (client) ID", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "sharepoint_client_secret": { + "category": "Storage Providers", + "description": "SharePoint Azure AD client secret", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": False, + }, + "sharepoint_tenant_id": { + "category": "Storage Providers", + "description": "SharePoint Azure AD tenant ID (use 'common' for multi-tenant apps)", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "sharepoint_refresh_token": { + "category": "Storage Providers", + "description": "SharePoint OAuth refresh token", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": False, + }, + "sharepoint_site_url": { + "category": "Storage Providers", + "description": "SharePoint site URL (e.g. https://tenant.sharepoint.com/sites/sitename)", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "sharepoint_document_library": { + "category": "Storage Providers", + "description": "SharePoint document library name (default: 'Documents')", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "sharepoint_folder_path": { + "category": "Storage Providers", + "description": "Subfolder path inside the SharePoint document library", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Storage Providers - WebDAV "webdav_enabled": { "category": "Storage Providers", @@ -2013,14 +2070,26 @@ SETTING_METADATA = { "category": "Backup", "description": ( "Storage provider for remote backup copies. " - "Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. " + "Accepted values: s3, dropbox, google_drive, onedrive, sharepoint, nextcloud, webdav, ftp, sftp, email. " "Leave empty to keep backups local only." ), "type": "string", "sensitive": False, "required": False, "restart_required": False, - "options": ["", "s3", "dropbox", "google_drive", "onedrive", "nextcloud", "webdav", "ftp", "sftp", "email"], + "options": [ + "", + "s3", + "dropbox", + "google_drive", + "onedrive", + "sharepoint", + "nextcloud", + "webdav", + "ftp", + "sftp", + "email", + ], }, "backup_remote_folder": { "category": "Backup", @@ -2532,6 +2601,72 @@ 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", + "description": ( + "Maximum number of uploads a single user may submit within upload_rate_limit_window seconds. " + "The health-aware limiter may reduce this dynamically under high Redis queue depth or CPU load. " + "Default: 20." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "upload_rate_limit_window": { + "category": "Security", + "description": ("Sliding window in seconds over which upload_rate_limit_per_user is enforced. Default: 60."), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Rate Limiting "rate_limiting_enabled": { "category": "Security", diff --git a/docs/API.md b/docs/API.md index f09d3bb1..de91311e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -121,7 +121,8 @@ curl -X GET "http:///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 @@ -2133,12 +2134,14 @@ Usage tracking records when each token was last used and from which IP address. ### POST /api/api-tokens/ -Create a new API token. +Create a new API token. Optionally specify a lifetime in days via +`expires_in_days` (1–3650). If omitted the token never expires. **Request:** ```json { - "name": "CI Pipeline" + "name": "CI Pipeline", + "expires_in_days": 90 } ``` @@ -2153,7 +2156,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" } ``` @@ -2175,15 +2179,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 @@ -2192,6 +2201,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: @@ -2274,9 +2290,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 diff --git a/docs/AuthenticationSetup.md b/docs/AuthenticationSetup.md index 2c9e15a9..c77f48b3 100644 --- a/docs/AuthenticationSetup.md +++ b/docs/AuthenticationSetup.md @@ -190,20 +190,23 @@ QR code login allows users to authenticate a mobile device by scanning a QR code ### How It Works 1. The authenticated web user opens the **QR Login** page and a challenge QR code is displayed. -2. The mobile app scans the QR code and calls the claim endpoint. -3. An API token is issued for the mobile device and the web UI is notified via polling. +2. The user opens the DocuElevate mobile app and taps **Scan QR Code to Login**, which opens the device camera. +3. The mobile app scans the QR code. The QR code contains both the challenge token and the server URL (`docuelevate://qr-login?token=...&server=...`), so there is no need to enter the server URL manually. +4. An API token is issued for the mobile device and the web UI is notified via polling. + +> **Note:** The countdown timer on the web page uses server-relative time (TTL in seconds) rather than absolute timestamps, so it works correctly even when the client's clock is not in sync with the server. ### Configuration | Variable | Description | Default | |----------|-------------|---------| -| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR challenge is valid | `120` | +| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR challenge is valid (seconds) | `120` | ### API Endpoints | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api/qr-auth/challenge` | Create a new QR login challenge | +| `POST` | `/api/qr-auth/challenge` | Create a new QR login challenge (returns `ttl_seconds` for client countdown) | | `GET` | `/api/qr-auth/challenge/{id}/status` | Poll the status of a challenge | | `POST` | `/api/qr-auth/claim` | Claim a challenge from a mobile device | diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 4c2121bb..884bd66b 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -1315,6 +1315,20 @@ For detailed setup instructions, see the [Google Drive Setup Guide](GoogleDriveS For detailed setup instructions, see the [OneDrive Setup Guide](OneDriveSetup.md). +### SharePoint Online + +| **Variable** | **Description** | +|---------------------------------|-------------------------------------------------------| +| `SHAREPOINT_CLIENT_ID` | Azure AD application client ID | +| `SHAREPOINT_CLIENT_SECRET` | Azure AD application client secret | +| `SHAREPOINT_TENANT_ID` | Azure AD tenant ID (use "common" for multi-tenant apps) | +| `SHAREPOINT_REFRESH_TOKEN` | OAuth 2.0 refresh token | +| `SHAREPOINT_SITE_URL` | SharePoint site URL (e.g. `https://tenant.sharepoint.com/sites/sitename`) | +| `SHAREPOINT_DOCUMENT_LIBRARY` | Document library name (default: `Documents`) | +| `SHAREPOINT_FOLDER_PATH` | Subfolder path inside the document library | + +SharePoint uses the same Microsoft Graph API as OneDrive. See the [OneDrive Setup Guide](OneDriveSetup.md) for Azure AD app registration instructions — the same app registration can be reused for SharePoint with the `Sites.ReadWrite.All` permission. + ### Amazon S3 | **Variable** | **Description** | @@ -1654,6 +1668,7 @@ For example: | S3 | `docs/uploads/` | `docs/uploads/pdfa/` | | Nextcloud | `/Files` | `/Files/pdfa` | | OneDrive | `Documents/Uploads` | `Documents/Uploads/pdfa` | +| SharePoint | `Uploads` | `Uploads/pdfa` | | Google Drive | *(folder ID)* | `GOOGLE_DRIVE_PDFA_FOLDER_ID` | Set `PDFA_UPLOAD_FOLDER` to an empty string to upload PDF/A files into the @@ -1860,6 +1875,15 @@ ONEDRIVE_TENANT_ID=common ONEDRIVE_REFRESH_TOKEN=your_refresh_token ONEDRIVE_FOLDER_PATH=Documents/Uploads +# SharePoint Online +SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012 +SHAREPOINT_CLIENT_SECRET=your_client_secret +SHAREPOINT_TENANT_ID=your-tenant-id +SHAREPOINT_REFRESH_TOKEN=your_refresh_token +SHAREPOINT_SITE_URL=https://tenant.sharepoint.com/sites/sitename +SHAREPOINT_DOCUMENT_LIBRARY=Documents +SHAREPOINT_FOLDER_PATH=Uploads + # Amazon S3 AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY diff --git a/docs/CredentialRotationGuide.md b/docs/CredentialRotationGuide.md index bb911752..3dbe3c20 100644 --- a/docs/CredentialRotationGuide.md +++ b/docs/CredentialRotationGuide.md @@ -11,7 +11,7 @@ Credentials fall into two categories: | Category | Examples | |---|---| | **API keys** | OpenAI API key, Azure AI key, Paperless-ngx API token, AWS access keys | -| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, Authentik client secrets and refresh tokens | +| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, SharePoint, Authentik client secrets and refresh tokens | | **Passwords** | Admin password, Nextcloud, Email (SMTP), IMAP, FTP, SFTP, WebDAV | | **Private keys** | SFTP private key and passphrase | @@ -119,6 +119,15 @@ For service-account credentials (`google_drive_credentials_json`): 4. Re-authorize via the OAuth flow to get a fresh `onedrive_refresh_token`. 5. Delete the old client secret in Azure. +### SharePoint (Microsoft OAuth) + +1. SharePoint uses the same Azure AD app registration as OneDrive. +2. In **Azure App Registrations**, navigate to **Certificates & secrets** for your app. +3. Add a new client secret. +4. Update `sharepoint_client_secret` in DocuElevate. +5. Re-authorize via the OAuth flow to get a fresh `sharepoint_refresh_token`. +6. Delete the old client secret in Azure. + ### Authentik (OIDC) 1. In your Authentik admin panel, navigate to the DocuElevate application and regenerate the client secret. diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index cb66bfa9..94dc634f 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -19,7 +19,7 @@ This guide covers all supported deployment methods for DocuElevate. - Access to required external services (if configured): - AI provider API key (OpenAI, Anthropic, Gemini, or other configured provider) - Azure Document Intelligence - - Dropbox, Google Drive, OneDrive, S3, or other storage APIs + - Dropbox, Google Drive, OneDrive, SharePoint, S3, or other storage APIs - SMTP / IMAP server (for email processing) - Notification services (Discord, Telegram, etc.) diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 77df917f..139c1456 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -8,6 +8,7 @@ DocuElevate includes a native mobile application for iOS and Android built with |---------|-----|---------| | SSO login (OAuth2) | ✅ | ✅ | | Local / basic auth login | ✅ | ✅ | +| QR code login (scan from web) | ✅ | ✅ | | Auto-generated API token | ✅ | ✅ | | Camera capture → upload | ✅ | ✅ | | File picker upload | ✅ | ✅ | @@ -112,6 +113,18 @@ When developing with **Expo Go** the app does not have the `docuelevate://` cust No extra configuration is needed — just run `npx expo start` and scan the QR code with the **Expo Go** app. +### QR Code Login Flow + +As an alternative to SSO, users can log in by scanning a QR code displayed in the web UI: + +1. The authenticated web user navigates to **Profile → Security & Sessions → Log in on mobile via QR code**. +2. A QR code is displayed containing a deep link: `docuelevate://qr-login?token=&server=`. +3. In the mobile app, the user taps **Scan QR Code to Login**, which opens the device camera. +4. The app scans the QR code, extracts both the server URL and the challenge token, and calls `POST /api/qr-auth/claim`. +5. An API token is issued and stored securely — no need to enter the server URL manually. + +> **Note:** The QR code already contains the server URL, so users do not need to type it in when using QR login. + ### Auto-generated Mobile Token When the mobile app completes login it automatically creates a named API token (`"Mobile App – "`) via `POST /api/mobile/generate-token`. This token: @@ -185,6 +198,24 @@ 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 and immediately redirects to the Upload tab. The `Linking` listener registered in the root layout has concurrently (or will shortly) added the file to `ShareContext`, so the upload proceeds normally once the user lands on the Upload tab. + +##### 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. @@ -286,7 +317,8 @@ mobile/ │ ├── (auth)/ # Unauthenticated route group │ │ ├── _layout.tsx # Stack navigator (headerless) │ │ ├── index.tsx # Welcome screen -│ │ └── login.tsx # Login screen +│ │ ├── login.tsx # Login screen +│ │ └── qr-scanner.tsx # QR code scanner screen │ └── (tabs)/ # Authenticated route group │ ├── _layout.tsx # Tab navigator │ ├── index.tsx # Upload screen (default tab) @@ -303,7 +335,8 @@ mobile/ ├── hooks/ │ └── usePushNotifications.ts # Push token registration ├── screens/ - │ ├── LoginScreen.tsx # Server URL + SSO button + │ ├── 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 diff --git a/docs/README.md b/docs/README.md index a3a6a96c..bc210343 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ Welcome to the DocuElevate documentation. This directory contains comprehensive - [Google Drive Setup](GoogleDriveSetup.md) - How to set up Google Drive integration - [Dropbox Setup](DropboxSetup.md) - How to set up Dropbox integration - [OneDrive Setup](OneDriveSetup.md) - How to set up Microsoft OneDrive/Graph integration + - [SharePoint Setup](SharePointSetup.md) - How to set up Microsoft SharePoint Online integration - [Amazon S3 Setup](AmazonS3Setup.md) - How to set up Amazon S3 integration - [Authentication Setup](AuthenticationSetup.md) - How to set up user authentication - [Notifications Setup](NotificationsSetup.md) - How to set up system notifications diff --git a/docs/SettingsManagement.md b/docs/SettingsManagement.md index b64c1ef4..288c261e 100644 --- a/docs/SettingsManagement.md +++ b/docs/SettingsManagement.md @@ -28,7 +28,7 @@ Settings are organized into logical categories for easy navigation: - **Authentication**: Login settings, session secrets, OAuth configuration, admin group - **AI Services**: AI provider selection, model configuration, embeddings, and credentials (OpenAI, Azure, Anthropic, Gemini, Ollama, OpenRouter, Portkey, LiteLLM) - **OCR Engines**: OCR provider selection and configuration (Tesseract, EasyOCR, Mistral, Google DocAI, AWS Textract) -- **Storage Providers**: Dropbox, Google Drive, OneDrive, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless +- **Storage Providers**: Dropbox, Google Drive, OneDrive, SharePoint, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless - **Email**: SMTP configuration for sending emails - **IMAP**: Email ingestion configuration (supports two mailbox accounts) - **Monitoring**: Uptime Kuma integration diff --git a/docs/SharePointSetup.md b/docs/SharePointSetup.md new file mode 100644 index 00000000..560f1671 --- /dev/null +++ b/docs/SharePointSetup.md @@ -0,0 +1,185 @@ +# Setting up SharePoint Integration + +This guide explains how to set up the Microsoft SharePoint Online integration for DocuElevate. + +## Required Configuration Parameters + +| **Variable** | **Description** | +|---------------------------------|-------------------------------------------------------| +| `SHAREPOINT_CLIENT_ID` | Azure AD application client ID | +| `SHAREPOINT_CLIENT_SECRET` | Azure AD application client secret | +| `SHAREPOINT_TENANT_ID` | Azure AD tenant ID (use "common" for multi-tenant apps) | +| `SHAREPOINT_REFRESH_TOKEN` | OAuth 2.0 refresh token | +| `SHAREPOINT_SITE_URL` | SharePoint site URL (e.g. `https://tenant.sharepoint.com/sites/sitename`) | +| `SHAREPOINT_DOCUMENT_LIBRARY` | Document library name (default: `Documents`) | +| `SHAREPOINT_FOLDER_PATH` | Subfolder path inside the document library | + +For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md). + +## Overview + +SharePoint Online integration uses the same Microsoft Graph API as OneDrive. The key difference is that SharePoint targets a **site-specific document library** rather than a personal OneDrive. Documents are uploaded via chunked upload sessions for reliability with large files. + +> **Tip:** If you already have an Azure AD app registration for OneDrive, you can reuse it for SharePoint — just add the `Sites.ReadWrite.All` permission. + +## Setup Steps + +### 1. Register an application in Azure Active Directory + +If you don't already have an app registration (e.g. from OneDrive setup): + +1. Go to the [Azure Portal](https://portal.azure.com/) +2. Navigate to **Azure Active Directory** > **App registrations** +3. Click **New registration** +4. Enter a name for your application (e.g., "DocuElevate") +5. For **Supported account types**, select: + - **Single tenant**: "Accounts in this organizational directory only" + - **Multi-tenant**: "Accounts in any organizational directory" +6. For **Redirect URI**, select "Web" and enter your callback URL (e.g., `https://your-domain.com/onedrive-callback`) +7. Click **Register** + +### 2. Get Application (client) ID + +1. After registration, note the **Application (client) ID** from the overview page +2. Set this value as `SHAREPOINT_CLIENT_ID` + +### 3. Create a client secret + +1. In your application page, go to **Certificates & secrets** +2. Under **Client secrets**, click **New client secret** +3. Add a description and select an expiration period +4. Click **Add** and immediately copy the secret value (it will only be shown once) +5. Set this value as `SHAREPOINT_CLIENT_SECRET` + +### 4. Configure API permissions + +1. In your application page, go to **API permissions** +2. Click **Add a permission** +3. Select **Microsoft Graph** +4. For **delegated permissions** (user-context access), add: + - `Sites.ReadWrite.All` — Read and write items in all site collections + - `offline_access` — Required for refresh tokens +5. For **application permissions** (app-only access without a user), add: + - `Sites.ReadWrite.All` — Read and write items in all site collections +6. Click **Add permissions** +7. Click **Grant admin consent** (requires admin privileges) + +> **Important:** SharePoint access requires `Sites.ReadWrite.All` rather than the `Files.ReadWrite` permission used by OneDrive. + +### 5. Get your Tenant ID + +1. In the Azure Portal, find your **Tenant ID** (also called "Directory ID") +2. It is on the **Azure Active Directory** overview page +3. Set this value as `SHAREPOINT_TENANT_ID` + +### 6. Generate a Refresh Token + +#### Using the OneDrive Auth Wizard + +The SharePoint integration reuses the same MSAL token flow as OneDrive: + +1. Navigate to `/onedrive-setup` +2. Enter your SharePoint Client ID and Tenant ID +3. Click **Start Authentication Flow** and follow the prompts +4. Copy the generated refresh token and set it as `SHAREPOINT_REFRESH_TOKEN` + +#### Manual Method + +1. Open the following URL in your browser (replace placeholders): + ``` + https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&response_mode=query&scope=https://graph.microsoft.com/.default offline_access&prompt=consent + ``` +2. Sign in with your Microsoft work account +3. After authentication, copy the `code` parameter from the redirect URL +4. Exchange the code for tokens: + ```bash + curl -X POST https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=YOUR_CLIENT_ID&scope=https://graph.microsoft.com/.default offline_access&code=YOUR_AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&grant_type=authorization_code&client_secret=YOUR_CLIENT_SECRET" + ``` +5. From the response JSON, copy the `refresh_token` value +6. Set this as `SHAREPOINT_REFRESH_TOKEN` + +### 7. Find your SharePoint Site URL + +Your SharePoint site URL follows the pattern: +``` +https://YOUR-TENANT.sharepoint.com/sites/SITE-NAME +``` + +For example: +- `https://contoso.sharepoint.com/sites/documents` +- `https://contoso.sharepoint.com/sites/engineering-team` + +Set this as `SHAREPOINT_SITE_URL`. + +### 8. Choose your Document Library + +Each SharePoint site has one or more document libraries. The default library is usually called `Documents` (or `Shared Documents`). You can find your library names by navigating to your SharePoint site in a browser and looking at the left sidebar. + +Set the library name as `SHAREPOINT_DOCUMENT_LIBRARY` (default: `Documents`). + +### 9. Set the Upload Folder (Optional) + +If you want documents to be uploaded into a subfolder inside the library, set `SHAREPOINT_FOLDER_PATH`. For example, `Uploads` or `DocuElevate/Processed`. + +## App-Only Access (No User Token) + +For fully automated scenarios without user interaction: + +1. Add **Application permissions** (not Delegated) for `Sites.ReadWrite.All` +2. Grant admin consent +3. Set `SHAREPOINT_TENANT_ID` to your organization's tenant ID +4. Leave `SHAREPOINT_REFRESH_TOKEN` empty — the app will use the client credentials flow + +> **Note:** Client credentials flow requires a specific tenant ID (not "common"). + +## Configuration Examples + +**With Refresh Token (Delegated Permissions):** +```dotenv +SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012 +SHAREPOINT_CLIENT_SECRET=your_client_secret +SHAREPOINT_TENANT_ID=87654321-4321-4321-4321-210987654321 +SHAREPOINT_REFRESH_TOKEN=your_refresh_token +SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/documents +SHAREPOINT_DOCUMENT_LIBRARY=Documents +SHAREPOINT_FOLDER_PATH=Uploads +``` + +**App-Only Access (Application Permissions):** +```dotenv +SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012 +SHAREPOINT_CLIENT_SECRET=your_client_secret +SHAREPOINT_TENANT_ID=87654321-4321-4321-4321-210987654321 +# No refresh token needed for app-only access +SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/documents +SHAREPOINT_DOCUMENT_LIBRARY=Shared Documents +SHAREPOINT_FOLDER_PATH=DocuElevate/Processed +``` + +## Troubleshooting + +### "Failed to resolve SharePoint site" + +- Verify `SHAREPOINT_SITE_URL` is correct and accessible +- Ensure your app has `Sites.ReadWrite.All` permission with admin consent +- Check that the site exists and your account has access to it + +### "Document library not found" + +- Verify the library name in `SHAREPOINT_DOCUMENT_LIBRARY` matches exactly (case-insensitive) +- Navigate to your SharePoint site in a browser to confirm the library name +- Common names: `Documents`, `Shared Documents` + +### Token errors + +- If using a refresh token, try re-authorizing via the OAuth flow +- Ensure `offline_access` scope is included in your permissions +- For app-only access, verify the tenant ID is not set to "common" + +### Permission errors + +- Ensure an admin has granted consent for `Sites.ReadWrite.All` +- Verify the app registration has the correct permissions +- Check that the site's sharing settings allow API access diff --git a/docs/StorageArchitecture.md b/docs/StorageArchitecture.md index af055406..399b89a8 100644 --- a/docs/StorageArchitecture.md +++ b/docs/StorageArchitecture.md @@ -341,6 +341,7 @@ in task messages or logs. | `S3` | boto3 `upload_file`, per-user access key | | `GOOGLE_DRIVE` | Google Drive API v3, OAuth or service account | | `ONEDRIVE` | Microsoft Graph API, MSAL confidential-client | +| `SHAREPOINT` | Microsoft Graph API, site/drive resolution + chunked upload | | `WEBDAV` | HTTP PUT request, Basic Auth | | `NEXTCLOUD` | WebDAV (same as WEBDAV, Nextcloud-compatible path) | | `FTP` | ftplib FTPS (TLS preferred, plaintext configurable) | diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 8d6b0a82..cd8afdd1 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -170,7 +170,7 @@ The **Integrations** page (`/integrations`) provides a unified view of all your - **S3** — bucket, region, access key, secret key - **WebDAV / Nextcloud** — URL, folder, username, password - **FTP / SFTP** — host, port, remote path, username, password - - **Dropbox / Google Drive / OneDrive** — folder path, with a link to the OAuth setup page + - **Dropbox / Google Drive / OneDrive / SharePoint** — folder path, with a link to the OAuth setup page - **Email Forward** — recipient email address - **Watch Folder** — source type (Local, S3, Dropbox, Google Drive, OneDrive, Nextcloud, WebDAV), per-type config fields, delete after processing toggle - **Paperless NGX** — URL and API token diff --git a/frontend/templates/api_tokens.html b/frontend/templates/api_tokens.html index 5e4633cf..b4facee8 100644 --- a/frontend/templates/api_tokens.html +++ b/frontend/templates/api_tokens.html @@ -33,6 +33,20 @@ aria-required="true" /> + + {{ _("api_tokens.expires_in_days_label") }} + + {{ _("api_tokens.col_created") }} {{ _("api_tokens.col_last_used") }} {{ _("api_tokens.col_last_ip") }} + {{ _("api_tokens.col_expires") }} {{ _("common.status") }} {{ _("common.actions") }} @@ -162,28 +177,62 @@ — + + + + - + {{ _("api_tokens.revoke") }} + + + + {{ _("api_tokens.reactivate") }} + + + + + {{ _("api_tokens.delete") }} + @@ -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); diff --git a/frontend/templates/devices.html b/frontend/templates/devices.html index 40e0a255..498fd486 100644 --- a/frontend/templates/devices.html +++ b/frontend/templates/devices.html @@ -83,20 +83,51 @@ > + - + {{ _("devices.revoke_token") }} + + + + {{ _("devices.reactivate_token") }} + + + + + {{ _("devices.delete_token") }} + @@ -182,16 +213,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')" > - + {{ _("devices.deactivate_device") }} + + + + {{ _("devices.delete_device") }} + @@ -242,8 +288,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 +332,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 +347,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 +410,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; } }, diff --git a/frontend/templates/files.html b/frontend/templates/files.html index 51d5d48a..1d142a03 100644 --- a/frontend/templates/files.html +++ b/frontend/templates/files.html @@ -524,6 +524,7 @@ WebDAV FTP SFTP + SharePoint iCloud Drive diff --git a/frontend/templates/help.html b/frontend/templates/help.html index 277c654a..aa696092 100644 --- a/frontend/templates/help.html +++ b/frontend/templates/help.html @@ -33,7 +33,7 @@ "name": "Which cloud storage providers does DocuElevate support?", "acceptedAnswer": { "@type": "Answer", - "text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx." + "text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, SharePoint, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx." } }, { @@ -188,6 +188,14 @@ + + + + SharePoint + Upload to SharePoint Online document libraries via Graph API. + + + diff --git a/frontend/templates/qr_login.html b/frontend/templates/qr_login.html index 517dd3ca..a2ecac26 100644 --- a/frontend/templates/qr_login.html +++ b/frontend/templates/qr_login.html @@ -32,7 +32,13 @@ id="qr-container" aria-label="{{ _('qr_login.description') }}" > - + {{ _("qr_login.description") }} @@ -110,8 +116,7 @@ - - +
Upload to SharePoint Online document libraries via Graph API.
{{ _("qr_login.description") }} @@ -110,8 +116,7 @@