221 lines
7.5 KiB
Python
221 lines
7.5 KiB
Python
"""Uploading, listing and downloading attachments.
|
|
|
|
## Why downloads go through here rather than through a static path
|
|
|
|
A file served from a directory a web server exposes is a URL: anyone who has it
|
|
can fetch it, for ever, from any workspace. Serving through the API means the
|
|
session, the workspace and the permission are checked on every read — and a
|
|
storage key that leaked in a log is still worth nothing.
|
|
|
|
## The headers, which are the actual security control
|
|
|
|
- **`Content-Type` is what we determined**, from the bytes, never what the
|
|
uploader claimed.
|
|
- **`X-Content-Type-Options: nosniff`** so a browser does not go looking for a
|
|
better idea than the one we gave it. Without it, `text/plain` containing HTML
|
|
is rendered as HTML by some browsers, from this origin.
|
|
- **`Content-Disposition: attachment`** for everything a browser might execute.
|
|
Only formats it cannot — PDF, PNG, JPEG, GIF, WebP — are served `inline`, which
|
|
is the difference between looking at an invoice and downloading it first.
|
|
- **`Content-Security-Policy: sandbox`** as the belt to that braces: even if the
|
|
type were somehow wrong, a sandboxed response has no origin to attack.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
from fastapi import (
|
|
APIRouter,
|
|
Depends,
|
|
File,
|
|
Form,
|
|
HTTPException,
|
|
Request,
|
|
UploadFile,
|
|
status,
|
|
)
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel, ConfigDict
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config.database import get_db
|
|
from app.config.settings import settings
|
|
from app.core import document_storage, file_types
|
|
from app.helper.helpers import get_client_ip
|
|
from app.middleware.auth_middleware import User, get_current_user, require_access
|
|
from app.models.system.document_model import Document
|
|
from app.services.system import document_service
|
|
from app.services.system.audit_log_service import AuditLogService
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class DocumentResponse(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
filename: str
|
|
content_type: str
|
|
size_bytes: int
|
|
entity_type: Optional[str] = None
|
|
entity_id: Optional[str] = None
|
|
description: Optional[str] = None
|
|
uploaded_by_id: Optional[uuid.UUID] = None
|
|
created_at: datetime
|
|
|
|
|
|
class StorageUsage(BaseModel):
|
|
used_bytes: int
|
|
quota_bytes: int
|
|
max_upload_bytes: int
|
|
|
|
|
|
def _workspace(user: User) -> uuid.UUID:
|
|
if user.tenant_id is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Account is not associated with a workspace")
|
|
return user.tenant_id
|
|
|
|
|
|
@router.get("/usage", response_model=StorageUsage)
|
|
def usage(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(require_access("admin.document.read")),
|
|
):
|
|
"""How much room is left.
|
|
|
|
Its own route because a screen shows it before somebody picks a file, and
|
|
finding out after the upload that there was no room is the worst moment to
|
|
find out.
|
|
"""
|
|
return StorageUsage(
|
|
used_bytes=document_service.used_bytes(db, _workspace(current_user)),
|
|
quota_bytes=settings.DOCUMENT_QUOTA_BYTES,
|
|
max_upload_bytes=settings.DOCUMENT_MAX_BYTES,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=List[DocumentResponse])
|
|
def list_documents(
|
|
entity_type: str,
|
|
entity_id: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(require_access("admin.document.read")),
|
|
):
|
|
"""Everything attached to one record.
|
|
|
|
Both parameters are required: a list of every document in a workspace is a
|
|
different screen with different pagination, and answering it here by
|
|
accident — because somebody omitted a filter — is how a listing turns into a
|
|
dump.
|
|
"""
|
|
rows = document_service.for_entity(
|
|
db, _workspace(current_user), entity_type, entity_id
|
|
)
|
|
return [DocumentResponse.model_validate(row) for row in rows]
|
|
|
|
|
|
@router.post("", response_model=DocumentResponse,
|
|
status_code=status.HTTP_201_CREATED)
|
|
def upload(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
entity_type: Optional[str] = Form(None),
|
|
entity_id: Optional[str] = Form(None),
|
|
description: Optional[str] = Form(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(require_access("admin.document.write")),
|
|
):
|
|
document = document_service.upload(
|
|
db,
|
|
tenant_id=_workspace(current_user),
|
|
uploaded_by=current_user,
|
|
filename=file.filename or "upload",
|
|
source=file.file,
|
|
entity_type=entity_type,
|
|
entity_id=entity_id,
|
|
description=description,
|
|
)
|
|
|
|
AuditLogService.log(
|
|
db=db, module_name="Documents", action_type="CREATE",
|
|
entity_id=str(document.id), entity_name=document.filename,
|
|
description="Document '" + document.filename + "' uploaded",
|
|
performed_by_id=str(current_user.id),
|
|
performed_by_email=current_user.email,
|
|
ip_address=get_client_ip(request),
|
|
tenant_id=current_user.tenant_id,
|
|
new_values={
|
|
"content_type": document.content_type,
|
|
"size_bytes": document.size_bytes,
|
|
"checksum": document.checksum,
|
|
},
|
|
)
|
|
db.commit()
|
|
db.refresh(document)
|
|
return DocumentResponse.model_validate(document)
|
|
|
|
|
|
@router.get("/{document_id}/content")
|
|
def download(
|
|
document_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(require_access("admin.document.read")),
|
|
):
|
|
document = document_service.get(db, _workspace(current_user), document_id)
|
|
|
|
if not document_storage.exists(document.storage_key):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="That file is no longer available")
|
|
|
|
disposition = file_types.disposition_for(document.content_type)
|
|
safe_name = file_types.safe_filename(document.filename)
|
|
|
|
return StreamingResponse(
|
|
document_storage.read(document.storage_key),
|
|
media_type=document.content_type,
|
|
headers={
|
|
"Content-Disposition": f'{disposition}; filename="{safe_name}"',
|
|
"Content-Length": str(document.size_bytes),
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Security-Policy": "sandbox; default-src 'none'",
|
|
"Cache-Control": "private, max-age=0, no-store",
|
|
},
|
|
)
|
|
|
|
|
|
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def remove(
|
|
document_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
_=Depends(require_access("admin.document.write")),
|
|
):
|
|
"""The bytes go; the record stays.
|
|
|
|
Storage costs money and a deleted file should stop costing it — but "who
|
|
removed that attachment, and when" is exactly what gets asked afterwards.
|
|
"""
|
|
document = document_service.get(db, _workspace(current_user), document_id)
|
|
name = document.filename
|
|
document_service.delete(db, document, current_user)
|
|
|
|
AuditLogService.log(
|
|
db=db, module_name="Documents", action_type="DELETE",
|
|
entity_id=str(document_id), entity_name=name,
|
|
description="Document '" + name + "' deleted",
|
|
performed_by_id=str(current_user.id),
|
|
performed_by_email=current_user.email,
|
|
ip_address=get_client_ip(request),
|
|
tenant_id=current_user.tenant_id,
|
|
)
|
|
db.commit()
|