diff --git a/app/api/__init__.py b/app/api/__init__.py index 66910467..dcee1703 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -7,6 +7,7 @@ import logging from fastapi import APIRouter from app.api.admin_users import router as admin_users_router +from app.api.api_tokens import router as api_tokens_router from app.api.azure import router as azure_router from app.api.backup import router as backup_router from app.api.billing import router as billing_router @@ -45,6 +46,7 @@ router = APIRouter() # Include all the routers router.include_router(admin_users_router) +router.include_router(api_tokens_router) router.include_router(user_router) router.include_router(backup_router) router.include_router(files_router) diff --git a/app/api/api_tokens.py b/app/api/api_tokens.py new file mode 100644 index 00000000..a53ba7f2 --- /dev/null +++ b/app/api/api_tokens.py @@ -0,0 +1,224 @@ +"""API endpoints for managing personal API tokens. + +Provides CRUD operations so users can create, list, and revoke tokens +that grant programmatic access to the DocuElevate API (e.g. webhook +uploads, scripted integrations). + +Tokens use ``secrets.token_urlsafe`` from the Python standard library +(no extra dependencies) and are prefixed with ``de_`` for easy +identification. Only a PBKDF2-HMAC-SHA256 hash is persisted; the +plaintext is returned exactly once at creation time. +""" + +import hashlib +import logging +import secrets +from datetime import datetime, timezone +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.database import get_db +from app.models import ApiToken +from app.utils.user_scope import get_current_owner_id + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api-tokens", tags=["api-tokens"]) + +DbSession = Annotated[Session, Depends(get_db)] + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +#: Prefix prepended to every generated token for easy identification. +TOKEN_PREFIX = "de_" +#: Number of random bytes for the token body (32 → 43 URL-safe chars). +TOKEN_BYTES = 32 +#: PBKDF2 iteration count for hashing API tokens. +TOKEN_HASH_ITERATIONS = 100_000 +#: PBKDF2 salt for API token hashing (not secret, but fixed for determinism). +TOKEN_HASH_SALT = b"api-token-v1" + + +# --------------------------------------------------------------------------- +# Auth helper +# --------------------------------------------------------------------------- + + +def _get_owner_id(request: Request) -> str: + """Return the current user's owner ID, raising 401 if unauthenticated.""" + owner_id = get_current_owner_id(request) + if not owner_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + return owner_id + + +CurrentOwner = Annotated[str, Depends(_get_owner_id)] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def generate_api_token() -> str: + """Generate a new API token with the ``de_`` prefix. + + Returns: + A URL-safe random token string, e.g. ``de_Ab3xY…``. + """ + return TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_BYTES) + + +def hash_token(token: str) -> str: + """Return a PBKDF2-HMAC-SHA256 hex digest of *token*. + + Args: + token: The plaintext API token. + + Returns: + 64-character lowercase hex string. + """ + dk = hashlib.pbkdf2_hmac( + "sha256", + token.encode("utf-8"), + TOKEN_HASH_SALT, + TOKEN_HASH_ITERATIONS, + ) + return dk.hex() + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +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") + + +class TokenResponse(BaseModel): + """Schema returned when listing tokens (plaintext is never included).""" + + id: int + name: str + token_prefix: str + is_active: bool + last_used_at: datetime | None + last_used_ip: str | None + created_at: datetime | None + revoked_at: datetime | None + + model_config = {"from_attributes": True} + + +class TokenCreatedResponse(TokenResponse): + """Schema returned once at creation time — includes the full plaintext token.""" + + token: str = Field(..., description="The full API token. Store it securely — it will not be shown again.") + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.post("/", status_code=status.HTTP_201_CREATED, response_model=TokenCreatedResponse) +async def create_token( + body: TokenCreate, + owner_id: CurrentOwner, + db: DbSession, +) -> dict[str, Any]: + """Create a new personal API token. + + The full token is returned **only once** in the response. Subsequent + ``GET`` requests will only show the prefix for identification. + """ + plaintext = generate_api_token() + token_hash_value = hash_token(plaintext) + prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total + + db_token = ApiToken( + owner_id=owner_id, + name=body.name, + token_hash=token_hash_value, + token_prefix=prefix, + ) + try: + db.add(db_token) + db.commit() + db.refresh(db_token) + except Exception: + db.rollback() + raise + + logger.info("API token created: id=%s owner=%s name=%r", db_token.id, owner_id, body.name) + + return { + "id": db_token.id, + "name": db_token.name, + "token_prefix": db_token.token_prefix, + "is_active": db_token.is_active, + "last_used_at": db_token.last_used_at, + "last_used_ip": db_token.last_used_ip, + "created_at": db_token.created_at, + "revoked_at": db_token.revoked_at, + "token": plaintext, + } + + +@router.get("/", response_model=list[TokenResponse]) +async def list_tokens( + owner_id: CurrentOwner, + db: DbSession, +) -> list[dict[str, Any]]: + """List all API tokens for the authenticated user.""" + tokens = db.query(ApiToken).filter(ApiToken.owner_id == owner_id).order_by(ApiToken.created_at.desc()).all() + return [ + { + "id": t.id, + "name": t.name, + "token_prefix": t.token_prefix, + "is_active": t.is_active, + "last_used_at": t.last_used_at, + "last_used_ip": t.last_used_ip, + "created_at": t.created_at, + "revoked_at": t.revoked_at, + } + for t in tokens + ] + + +@router.delete("/{token_id}", status_code=status.HTTP_200_OK) +async def revoke_token( + token_id: int, + owner_id: CurrentOwner, + db: DbSession, +) -> dict[str, str]: + """Revoke (soft-delete) an API token. + + The token row is kept for audit purposes but marked inactive with a + ``revoked_at`` timestamp. + """ + 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") + + 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"} diff --git a/app/api/pipelines.py b/app/api/pipelines.py index fc7bf851..3980d71d 100644 --- a/app/api/pipelines.py +++ b/app/api/pipelines.py @@ -291,15 +291,15 @@ def create_pipeline(request: Request, db: DbSession, body: PipelineCreate) -> di db.add(pipeline) db.commit() db.refresh(pipeline) - except Exception as exc: + except Exception: db.rollback() - logger.exception(f"Failed to create pipeline user={user_id}: {exc}") + logger.exception("Failed to create pipeline user=%s", user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create pipeline", ) - logger.info(f"Pipeline created: id={pipeline.id}, owner={user_id}, name={name!r}") + logger.info("Pipeline created: id=%s, owner=%s, name=%r", pipeline.id, user_id, name) return _serialize_pipeline(pipeline) @@ -387,15 +387,15 @@ def update_pipeline(pipeline_id: int, request: Request, db: DbSession, body: Pip try: db.commit() db.refresh(pipeline) - except Exception as exc: + except Exception: db.rollback() - logger.exception(f"Failed to update pipeline id={pipeline_id}: {exc}") + logger.exception("Failed to update pipeline id=%s", pipeline_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update pipeline", ) - logger.info(f"Pipeline updated: id={pipeline_id}, user={user_id}") + logger.info("Pipeline updated: id=%s, user=%s", pipeline_id, user_id) return _serialize_pipeline(pipeline, include_steps=True, db=db) @@ -425,15 +425,15 @@ def delete_pipeline(pipeline_id: int, request: Request, db: DbSession) -> None: db.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline_id).delete() db.delete(pipeline) db.commit() - except Exception as exc: + except Exception: db.rollback() - logger.exception(f"Failed to delete pipeline id={pipeline_id}: {exc}") + logger.exception("Failed to delete pipeline id=%s", pipeline_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete pipeline", ) - logger.info(f"Pipeline deleted: id={pipeline_id}, user={user_id}") + logger.info("Pipeline deleted: id=%s, user=%s", pipeline_id, user_id) # --------------------------------------------------------------------------- diff --git a/app/api/saved_searches.py b/app/api/saved_searches.py index 20c1f004..1e9923d1 100644 --- a/app/api/saved_searches.py +++ b/app/api/saved_searches.py @@ -190,15 +190,15 @@ def create_saved_search( db.add(saved_search) db.commit() db.refresh(saved_search) - except Exception as exc: + except Exception: db.rollback() - logger.exception(f"Failed to create saved search for user={user_id}: {exc}") + logger.exception("Failed to create saved search for user=%s", user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save search", ) - logger.info(f"Saved search created: user={user_id}, name={name!r}") + logger.info("Saved search created: user=%s, name=%r", user_id, name) return _serialize_saved_search(saved_search) @@ -263,15 +263,15 @@ def update_saved_search( try: db.commit() db.refresh(saved_search) - except Exception as exc: + except Exception: db.rollback() - logger.exception(f"Failed to update saved search id={search_id}, user={user_id}: {exc}") + logger.exception("Failed to update saved search id=%s, user=%s", search_id, user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update saved search", ) - logger.info(f"Saved search updated: id={search_id}, user={user_id}") + logger.info("Saved search updated: id=%s, user=%s", search_id, user_id) return _serialize_saved_search(saved_search) @@ -294,12 +294,12 @@ def delete_saved_search(search_id: int, request: Request, db: DbSession): try: db.delete(saved_search) db.commit() - except Exception as exc: + except Exception: db.rollback() - logger.exception(f"Failed to delete saved search id={search_id}, user={user_id}: {exc}") + logger.exception("Failed to delete saved search id=%s, user=%s", search_id, user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete saved search", ) - logger.info(f"Saved search deleted: id={search_id}, user={user_id}") + logger.info("Saved search deleted: id=%s, user=%s", search_id, user_id) diff --git a/app/auth.py b/app/auth.py index fe2f439e..8811df94 100644 --- a/app/auth.py +++ b/app/auth.py @@ -2,6 +2,7 @@ import hashlib import inspect import logging import pathlib +from datetime import datetime, timezone from functools import wraps from urllib.parse import urlparse @@ -52,9 +53,62 @@ router = APIRouter() def get_current_user(request: Request): + # Check for Bearer token auth first (API tokens) + api_user = getattr(request.state, "api_token_user", None) + if isinstance(api_user, dict): + return api_user return request.session.get("user") +def _resolve_bearer_user(request: Request, db: Session) -> dict | None: + """Resolve a user from a Bearer API token in the Authorization header. + + If the header is present and the token is valid, updates usage tracking + (last_used_at, last_used_ip) and returns a synthetic user dict compatible + with the session user format. + + Returns: + A user dict or ``None`` if no valid Bearer token is present. + """ + auth_header = request.headers.get("authorization", "") + if not isinstance(auth_header, str) or not auth_header.startswith("Bearer "): + return None + + raw_token = auth_header[7:] + if not raw_token or not isinstance(raw_token, str): + return None + + from app.api.api_tokens import hash_token + from app.models import ApiToken + + token_hash = hash_token(raw_token) + db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first() + if db_token is None: + return None + + # Update usage tracking + try: + db_token.last_used_at = datetime.now(timezone.utc) + # Extract client IP (respect X-Forwarded-For from reverse proxy) + client_ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() + if not client_ip and request.client: + client_ip = request.client.host + db_token.last_used_ip = client_ip or None + db.commit() + except Exception: + db.rollback() + logger.debug("Failed to update API token usage tracking for token_id=%s", db_token.id) + + # Build a synthetic user dict that mimics the session user format + return { + "id": db_token.owner_id, + "email": db_token.owner_id, + "preferred_username": db_token.owner_id, + "is_admin": False, + "_api_token_id": db_token.id, + } + + def get_current_user_id(request: Request) -> str: """Return a stable string identifier for the authenticated user. @@ -82,28 +136,42 @@ def require_login(func): @wraps(func) async def wrapper(request: Request, *args, **kwargs): - if not request.session.get("user"): - # For API endpoints return 401 instead of storing the URL in the session - # and redirecting to /login. Without this guard, the /api/auth/whoami - # probe issued by common.js on every page load would overwrite - # redirect_after_login with the API URL, causing the post-login redirect - # to land on a JSON endpoint rather than the original page. - url_path = urlparse(str(request.url)).path - if url_path.startswith("/api/"): - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"error": "Not authenticated"}, - ) - request.session["redirect_after_login"] = str(request.url) - return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) - # Pass request as a keyword argument so that endpoints whose first - # parameter is a path variable (e.g. pipeline_id) are not accidentally - # bound to the request object when FastAPI supplies all arguments as - # keyword arguments. - if inspect.iscoroutinefunction(func): - return await func(*args, request=request, **kwargs) - else: - return func(*args, request=request, **kwargs) + # Check session auth first + if request.session.get("user"): + if inspect.iscoroutinefunction(func): + return await func(*args, request=request, **kwargs) + else: + return func(*args, request=request, **kwargs) + + # Fall back to Bearer token auth for API endpoints + url_path = urlparse(str(request.url)).path + if url_path.startswith("/api/"): + try: + from app.database import SessionLocal + + db = SessionLocal() + try: + api_user = _resolve_bearer_user(request, db) + finally: + db.close() + except Exception: + api_user = None + + if api_user: + request.state.api_token_user = api_user + if inspect.iscoroutinefunction(func): + return await func(*args, request=request, **kwargs) + else: + return func(*args, request=request, **kwargs) + + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"error": "Not authenticated"}, + ) + + # Non-API endpoint with no session — redirect to login + request.session["redirect_after_login"] = str(request.url) + return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) return wrapper @@ -443,7 +511,7 @@ if AUTH_ENABLED: @require_login async def whoami(request: Request): """API endpoint to get current user information""" - user = request.session.get("user") + user = get_current_user(request) return user or {"error": "Not authenticated"} diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index dce8dda1..842958af 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -109,6 +109,13 @@ class CSRFMiddleware(BaseHTTPMiddleware): # Validate for state-changing methods on non-exempt paths. if request.method in CSRF_PROTECTED_METHODS and request.url.path not in CSRF_EXEMPT_PATHS: + # Bearer-authenticated requests (API tokens) are exempt from CSRF + # because the token itself acts as proof of intent — it cannot be + # injected by a cross-site request from a browser. + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return await call_next(request) + submitted_token = await self._get_submitted_token(request) if not submitted_token or not secrets.compare_digest(csrf_token, submitted_token): logger.warning(f"[SECURITY] CSRF_VALIDATION_FAILED method={request.method} path={request.url.path}") diff --git a/app/models.py b/app/models.py index 733e5ed5..687bf3da 100644 --- a/app/models.py +++ b/app/models.py @@ -600,3 +600,40 @@ class UserIntegration(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ApiToken(Base): + """Personal API token for programmatic access. + + Users can create multiple tokens, each with a human-readable name. + Only the SHA-256 hash of the token is stored; the plaintext is shown + exactly once at creation time. A short prefix (first 8 chars) is + persisted for easy identification in the UI. + + Usage tracking records the timestamp and IP address of the most + recent request that used the token. + """ + + __tablename__ = "api_tokens" + + id = Column(Integer, primary_key=True, index=True) + + # Stable owner identifier — matches FileRecord.owner_id / UserIntegration.owner_id + owner_id = Column(String, nullable=False, index=True) + + # Human-readable label chosen by the user (e.g. "CI Pipeline", "Webhook Upload") + name = Column(String(255), nullable=False) + + # SHA-256 hex digest of the full token value + token_hash = Column(String(64), nullable=False, unique=True, index=True) + + # First 12 characters of the token for display (e.g. "de_Ab3xY7kL…") + token_prefix = Column(String(16), nullable=False) + + # Usage tracking + last_used_at = Column(DateTime(timezone=True), nullable=True) + last_used_ip = Column(String(45), nullable=True) # IPv6 max length + + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + revoked_at = Column(DateTime(timezone=True), nullable=True) diff --git a/app/views/__init__.py b/app/views/__init__.py index ca2e9952..b8fb9b0d 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -5,6 +5,7 @@ Aggregated view routers for the application. from fastapi import APIRouter from app.views.admin_users import router as admin_users_router +from app.views.api_tokens import router as api_tokens_router from app.views.backup import router as backup_router from app.views.db_wizard import router as db_wizard_router from app.views.dropbox import router as dropbox_router @@ -33,6 +34,7 @@ router = APIRouter() router.include_router(wizard_router) # Wizard first (for /setup) router.include_router(db_wizard_router) # Database wizard router.include_router(admin_users_router) # Admin user management +router.include_router(api_tokens_router) # API token management router.include_router(backup_router) # Backup dashboard router.include_router(general_router) router.include_router(status_router) diff --git a/app/views/api_tokens.py b/app/views/api_tokens.py new file mode 100644 index 00000000..3475e9b3 --- /dev/null +++ b/app/views/api_tokens.py @@ -0,0 +1,24 @@ +"""View route for the API Tokens management page. + +Renders the ``api_tokens.html`` template where users can create, view, +and revoke their personal API tokens for programmatic access. +""" + +import logging + +from fastapi import APIRouter, Request + +from app.views.base import require_login, templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/api-tokens") +@require_login +async def api_tokens_page(request: Request): + """Render the API Tokens management page.""" + return templates.TemplateResponse( + "api_tokens.html", + {"request": request, "page_title": "API Tokens"}, + ) diff --git a/docs/API.md b/docs/API.md index 2d837977..7766e8d9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -86,7 +86,38 @@ def make_api_request(url, max_retries=3): ## Authentication -When authentication is enabled, you must include an authentication token in your requests: +When authentication is enabled, you must include an authentication token in your requests. + +### API Tokens (Recommended) + +DocuElevate supports personal API tokens for programmatic access. Tokens are the recommended +way to authenticate scripts, CI/CD pipelines, and webhook integrations. + +**Creating a token:** + +1. Log in to DocuElevate and navigate to **API Tokens** (available in your user menu or at `/api-tokens`). +2. Enter a descriptive name (e.g. "CI Pipeline", "Scanner Integration") and click **Create Token**. +3. Copy the token immediately — it is shown only once. + +**Using a token:** + +```bash +curl -X GET "http:///api/files" \ + -H "Authorization: Bearer " +``` + +**Managing tokens programmatically:** + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/api-tokens/` | Create a new token | +| `GET` | `/api/api-tokens/` | List all your tokens | +| `DELETE` | `/api/api-tokens/{id}` | Revoke a token | + +### Session Authentication + +Browser-based users authenticate via OAuth or local login. Session cookies are set +automatically and used for subsequent requests: ```bash curl -X GET "http:///api/files" \ @@ -1900,6 +1931,106 @@ Pass no `pipeline_id` query parameter (or omit it) to clear the assignment. ``` +## API Tokens + +Personal API tokens allow programmatic access to the DocuElevate API without +session cookies. Tokens are ideal for CI/CD pipelines, webhook integrations, +and automation scripts. + +Each token is prefixed with `de_` for easy identification. Only a SHA-256 hash +is stored server-side; the plaintext is returned exactly once at creation time. + +Usage tracking records when each token was last used and from which IP address. + +### POST /api/api-tokens/ + +Create a new API token. + +**Request:** +```json +{ + "name": "CI Pipeline" +} +``` + +**Response (201 Created):** +```json +{ + "id": 1, + "name": "CI Pipeline", + "token_prefix": "de_Ab3xY7kL", + "token": "de_Ab3xY7kLmN9pQrStUvWxYz0123456789abcdef", + "is_active": true, + "last_used_at": null, + "last_used_ip": null, + "created_at": "2026-03-08T12:00:00Z", + "revoked_at": null +} +``` + +> **Important:** The `token` field is only included in the creation response. +> Copy it immediately — it will not be shown again. + +### GET /api/api-tokens/ + +List all tokens for the authenticated user. The full token value is never included. + +**Response (200):** +```json +[ + { + "id": 1, + "name": "CI Pipeline", + "token_prefix": "de_Ab3xY7kL", + "is_active": true, + "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 + } +] +``` + +### 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. + +**Response (200):** +```json +{ + "detail": "Token revoked" +} +``` + +### Using API Tokens + +Include the token in the `Authorization` header of any API request: + +```bash +# Upload a document +curl -X POST "http://your-instance/api/files/ui-upload" \ + -H "Authorization: Bearer de_your_token_here" \ + -F "file=@/path/to/document.pdf" + +# List files +curl -X GET "http://your-instance/api/files" \ + -H "Authorization: Bearer de_your_token_here" +``` + +**Python example:** +```python +import requests + +response = requests.post( + "http://your-instance/api/files/ui-upload", + headers={"Authorization": "Bearer de_your_token_here"}, + files={"file": open("document.pdf", "rb")}, +) +print(response.json()) +``` + + ## Further Assistance For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md). diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 7cb85543..7ad1776d 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -165,6 +165,7 @@ The **Integrations** page (`/integrations`) provides a unified view of all your - **Email Forward** — recipient email address - **Watch Folder** — folder path - **Paperless NGX** — URL and API token + - **Webhook** — no configuration needed; the form shows a quick-start guide with sample `curl` and Python snippets for uploading documents via the API 5. Click **Test Connection** to verify the settings before saving. 6. Click **Save** to persist the integration. @@ -193,6 +194,52 @@ DocuElevate can poll an FTP or SFTP directory for new files. Enable this with `F See [Configuration Guide — Watch Folder Ingestion](ConfigurationGuide.md#watch-folder-ingestion) for full setup instructions. +### Webhook Ingestion (API Upload) + +Webhook integrations allow external systems to push documents directly into DocuElevate +via the REST API. Instead of DocuElevate polling for new files, **your application sends +files to DocuElevate** using an HTTP request with an API token for authentication. + +This is ideal for: +- **CI/CD pipelines** — upload build artifacts or generated reports +- **Scanner integrations** — push scanned documents from network scanners +- **Automation scripts** — send documents from any system with HTTP support + +To set up webhook ingestion: + +1. Go to **Integrations** and add a new **Source → Webhook** integration. + The integration form shows copy-ready code snippets. +2. Go to **API Tokens** (in your user menu) and create a personal API token. +3. Use the token to upload documents: + +```bash +curl -X POST "https://your-instance/api/files/ui-upload" \ + -H "Authorization: Bearer de_your_token_here" \ + -F "file=@/path/to/document.pdf" +``` + +See the [API Documentation](API.md#api-tokens) for detailed API token management. + +### API Tokens + +API tokens provide secure, programmatic access to the DocuElevate API. Each user +can create multiple tokens, each with a descriptive name. + +**Features:** +- **Multiple tokens** — create separate tokens for different scripts or integrations +- **Usage tracking** — see when each token was last used and from which IP address +- **Revocation** — instantly disable a compromised token without affecting others + +**Managing tokens:** + +1. Click your avatar in the top navigation and select **API Tokens**, or go to `/api-tokens`. +2. Enter a descriptive name and click **Create Token**. +3. Copy the token immediately — it is shown only once. +4. To revoke a token, click **Revoke** next to it in the token list. + +> **Security tip:** Create a dedicated token for each integration and revoke it +> immediately if compromised. Never share tokens or commit them to source control. + ## Managing Documents The **Files** page provides access to all processed documents: diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index cd71b570..3436cb5c 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -209,6 +209,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { linksDiv.appendChild( _makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700') ); + linksDiv.appendChild( + _makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700') + ); // Divider + Sign Out const divider = document.createElement('div'); @@ -278,6 +281,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') { subLink.appendChild(document.createTextNode('My Subscription')); mobileAuthSection.appendChild(subLink); + // API Tokens link + const tokensLink = document.createElement('a'); + tokensLink.href = '/api-tokens'; + tokensLink.className = + 'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50'; + const tokensIcon = document.createElement('i'); + tokensIcon.className = 'fas fa-key mr-2 text-yellow-500'; + tokensIcon.setAttribute('aria-hidden', 'true'); + tokensLink.appendChild(tokensIcon); + tokensLink.appendChild(document.createTextNode('API Tokens')); + mobileAuthSection.appendChild(tokensLink); + // Logout link const logoutLink = document.createElement('a'); logoutLink.href = '/logout'; diff --git a/frontend/templates/api_tokens.html b/frontend/templates/api_tokens.html new file mode 100644 index 00000000..bb8f7f2e --- /dev/null +++ b/frontend/templates/api_tokens.html @@ -0,0 +1,311 @@ +{% extends "base.html" %} + +{% block title %}API Tokens – DocuElevate{% endblock %} + +{% block content %} +
+
+

+ + API Tokens +

+

+ Create personal API tokens to interact with the DocuElevate API programmatically. + Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload + or retrieve documents. +

+
+ + +
+

Create New Token

+
+
+ + +
+ +
+ + + +
+ + +
+

+ + Usage Example +

+

+ Use your API token in the Authorization + header with any API request: +

+
+
curl -X POST "/api/files/ui-upload" \
+  -H "Authorization: Bearer YOUR_API_TOKEN" \
+  -F "file=@/path/to/document.pdf"
+ +
+
+ + +
+
+

Your Tokens

+
+ + + + + + + + + + + + +
+
+ + +{% endblock %} diff --git a/frontend/templates/integrations_dashboard.html b/frontend/templates/integrations_dashboard.html index 205e8a99..5b71aaa7 100644 --- a/frontend/templates/integrations_dashboard.html +++ b/frontend/templates/integrations_dashboard.html @@ -628,8 +628,97 @@ - -