mock server, file and folder management

This commit is contained in:
2026-06-12 02:18:30 +02:00
parent b67c8abdc5
commit f3db5fc5cf
28 changed files with 3049 additions and 6 deletions

641
server/app/api/v1/files.py Normal file
View File

@@ -0,0 +1,641 @@
from __future__ import annotations
from io import BytesIO
from typing import Any, Literal
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.dependencies import ApiPrincipal, require_scope
from app.db.models import Campaign, FileAsset, FileFolder, FileShare, Group
from app.db.session import get_session
from app.storage.paths import UnsafeFilePathError, filename_from_path, normalize_folder, normalize_logical_path
from app.storage.services import (
FileStorageError,
asset_is_audit_relevant,
build_rename_preview,
create_file_asset,
create_folder,
create_zip_bytes,
current_version_and_blob,
extract_zip_upload,
get_asset_for_user,
list_assets_for_user,
list_folders_for_user,
rename_asset,
resolve_patterns,
share_file,
soft_delete_assets,
soft_delete_folder,
user_group_ids,
read_asset_bytes,
)
router = APIRouter(prefix="/files", tags=["files"])
class FileSpaceResponse(BaseModel):
id: str
label: str
owner_type: Literal["user", "group"]
owner_id: str
description: str | None = None
class FileSpacesResponse(BaseModel):
spaces: list[FileSpaceResponse]
class FileShareResponse(BaseModel):
id: str
target_type: str
target_id: str
permission: str
created_at: str
revoked_at: str | None = None
class FileAssetResponse(BaseModel):
id: str
tenant_id: str
owner_type: str
owner_id: str
display_path: str
filename: str
description: str | None = None
size_bytes: int
content_type: str | None = None
checksum_sha256: str
version_id: str
created_at: str
updated_at: str
deleted_at: str | None = None
audit_relevant: bool = False
metadata: dict[str, Any] | None = None
shares: list[FileShareResponse] = Field(default_factory=list)
class FileFolderResponse(BaseModel):
id: str
tenant_id: str
owner_type: str
owner_id: str
path: str
created_at: str
updated_at: str
deleted_at: str | None = None
class FileFoldersResponse(BaseModel):
folders: list[FileFolderResponse]
class FileFolderCreateRequest(BaseModel):
owner_type: Literal["user", "group"]
owner_id: str
path: str
class FileFolderDeleteRequest(BaseModel):
owner_type: Literal["user", "group"]
owner_id: str
path: str
recursive: bool = True
class FileFolderDeleteResponse(BaseModel):
deleted_folders: int
deleted_files: int
class FileListResponse(BaseModel):
files: list[FileAssetResponse]
class FileUploadResponse(BaseModel):
files: list[FileAssetResponse]
class BulkDeleteRequest(BaseModel):
file_ids: list[str]
class BulkDeleteResponse(BaseModel):
deleted_count: int
class FileShareRequest(BaseModel):
target_type: Literal["user", "group", "campaign", "tenant"]
target_id: str
permission: Literal["read", "write", "manage"] = "read"
class RenameRequest(BaseModel):
file_ids: list[str]
mode: Literal["prefix", "suffix", "replace"]
find: str | None = None
replacement: str = ""
prefix: str = ""
suffix: str = ""
dry_run: bool = True
class RenamePreviewItem(BaseModel):
file_id: str
old_path: str
new_path: str
class RenameResponse(BaseModel):
dry_run: bool
items: list[RenamePreviewItem]
class ArchiveRequest(BaseModel):
file_ids: list[str]
filename: str = "files.zip"
class PatternResolveRequest(BaseModel):
patterns: list[str]
owner_type: Literal["user", "group"] | None = None
owner_id: str | None = None
campaign_id: str | None = None
path_prefix: str | None = None
include_unmatched: bool = True
class PatternMatchResponse(BaseModel):
pattern: str
matches: list[FileAssetResponse]
class PatternResolveResponse(BaseModel):
patterns: list[PatternMatchResponse]
unmatched: list[FileAssetResponse] = Field(default_factory=list)
def _is_admin(principal: ApiPrincipal) -> bool:
return principal.user.is_tenant_admin or "*" in set(principal.scopes or [])
def _http_error(exc: Exception, *, not_found: bool = False) -> HTTPException:
code = status.HTTP_404_NOT_FOUND if not_found else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=code, detail=str(exc))
def _owner_id(asset: FileAsset) -> str:
return asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id # type: ignore[return-value]
def _asset_response(session: Session, asset: FileAsset, *, include_shares: bool = False) -> FileAssetResponse:
version, blob = current_version_and_blob(session, asset)
shares: list[FileShareResponse] = []
if include_shares:
rows = session.query(FileShare).filter(FileShare.file_asset_id == asset.id).order_by(FileShare.created_at.desc()).all()
shares = [
FileShareResponse(
id=row.id,
target_type=row.target_type,
target_id=row.target_id,
permission=row.permission,
created_at=row.created_at.isoformat(),
revoked_at=row.revoked_at.isoformat() if row.revoked_at else None,
)
for row in rows
]
return FileAssetResponse(
id=asset.id,
tenant_id=asset.tenant_id,
owner_type=asset.owner_type,
owner_id=_owner_id(asset),
display_path=asset.display_path,
filename=asset.filename,
description=asset.description,
size_bytes=blob.size_bytes,
content_type=blob.content_type,
checksum_sha256=blob.checksum_sha256,
version_id=version.id,
created_at=asset.created_at.isoformat(),
updated_at=asset.updated_at.isoformat(),
deleted_at=asset.deleted_at.isoformat() if asset.deleted_at else None,
audit_relevant=asset_is_audit_relevant(session, asset),
metadata=asset.metadata_ or {},
shares=shares,
)
def _folder_owner_id(folder: FileFolder) -> str:
return folder.owner_user_id if folder.owner_type == "user" else folder.owner_group_id # type: ignore[return-value]
def _folder_response(folder: FileFolder) -> FileFolderResponse:
return FileFolderResponse(
id=folder.id,
tenant_id=folder.tenant_id,
owner_type=folder.owner_type,
owner_id=_folder_owner_id(folder),
path=folder.path,
created_at=folder.created_at.isoformat(),
updated_at=folder.updated_at.isoformat(),
deleted_at=folder.deleted_at.isoformat() if folder.deleted_at else None,
)
def _ensure_list_owner_access(session: Session, principal: ApiPrincipal, owner_type: str | None, owner_id: str | None) -> None:
if not owner_type:
return
if owner_type == "user" and owner_id and owner_id != principal.user.id and not _is_admin(principal):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this user file space")
if owner_type == "group" and owner_id:
try:
from app.storage.services import ensure_group_access
ensure_group_access(
session,
tenant_id=principal.tenant_id,
group_id=owner_id,
user_id=principal.user.id,
is_admin=_is_admin(principal),
)
except FileStorageError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
@router.get("/spaces", response_model=FileSpacesResponse)
def list_file_spaces(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
spaces = [
FileSpaceResponse(
id=f"user:{principal.user.id}",
label="My files",
owner_type="user",
owner_id=principal.user.id,
description="Files owned by your user account.",
)
]
group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal))
if group_ids:
groups = session.query(Group).filter(Group.tenant_id == principal.tenant_id, Group.id.in_(group_ids)).order_by(Group.name.asc()).all()
spaces.extend(
FileSpaceResponse(
id=f"group:{group.id}",
label=f"{group.name} files",
owner_type="group",
owner_id=group.id,
description="Files owned by this group.",
)
for group in groups
)
return FileSpacesResponse(spaces=spaces)
@router.get("/folders", response_model=FileFoldersResponse)
def list_file_folders(
owner_type: Literal["user", "group"],
owner_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
folders = list_folders_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
owner_type=owner_type,
owner_id=owner_id,
is_admin=_is_admin(principal),
)
return FileFoldersResponse(folders=[_folder_response(folder) for folder in folders])
except FileStorageError as exc:
raise _http_error(exc) from exc
@router.post("/folders", response_model=FileFolderResponse)
def create_file_folder(
payload: FileFolderCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
folder = create_folder(
session,
tenant_id=principal.tenant_id,
owner_type=payload.owner_type,
owner_id=payload.owner_id,
user_id=principal.user.id,
path=payload.path,
is_admin=_is_admin(principal),
)
session.commit()
return _folder_response(folder)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/folders/delete", response_model=FileFolderDeleteResponse)
def delete_file_folder(
payload: FileFolderDeleteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
deleted_folders, deleted_files = soft_delete_folder(
session,
tenant_id=principal.tenant_id,
owner_type=payload.owner_type,
owner_id=payload.owner_id,
user_id=principal.user.id,
path=payload.path,
recursive=payload.recursive,
is_admin=_is_admin(principal),
)
session.commit()
return FileFolderDeleteResponse(deleted_folders=deleted_folders, deleted_files=deleted_files)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.get("", response_model=FileListResponse)
def list_files(
owner_type: Literal["user", "group"] | None = None,
owner_id: str | None = None,
campaign_id: str | None = None,
path_prefix: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
_ensure_list_owner_access(session, principal, owner_type, owner_id)
assets = list_assets_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
is_admin=_is_admin(principal),
)
return FileListResponse(files=[_asset_response(session, asset, include_shares=True) for asset in assets])
@router.post("/upload", response_model=FileUploadResponse)
async def upload_files(
files: list[UploadFile] = FastAPIFile(...),
owner_type: Literal["user", "group"] = Form(default="user"),
owner_id: str | None = Form(default=None),
path: str = Form(default=""),
campaign_id: str | None = Form(default=None),
unpack_zip: bool = Form(default=False),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
target_owner = owner_id or principal.user.id
uploaded_assets: list[FileAsset] = []
try:
for upload in files:
data = await upload.read()
filename = upload.filename or "file"
content_type = upload.content_type or None
if unpack_zip and filename.lower().endswith(".zip"):
extracted = extract_zip_upload(
session,
tenant_id=principal.tenant_id,
owner_type=owner_type,
owner_id=target_owner,
user_id=principal.user.id,
zip_data=data,
folder=path,
campaign_id=campaign_id,
is_admin=_is_admin(principal),
)
uploaded_assets.extend(item.asset for item in extracted)
continue
stored = create_file_asset(
session,
tenant_id=principal.tenant_id,
owner_type=owner_type,
owner_id=target_owner,
user_id=principal.user.id,
filename=filename,
data=data,
folder=path,
content_type=content_type,
campaign_id=campaign_id,
is_admin=_is_admin(principal),
)
uploaded_assets.append(stored.asset)
session.commit()
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
return FileUploadResponse(files=[_asset_response(session, asset, include_shares=True) for asset in uploaded_assets])
@router.post("/upload-zip", response_model=FileUploadResponse)
async def upload_zip(
file: UploadFile = FastAPIFile(...),
owner_type: Literal["user", "group"] = Form(default="user"),
owner_id: str | None = Form(default=None),
path: str = Form(default=""),
campaign_id: str | None = Form(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
data = await file.read()
target_owner = owner_id or principal.user.id
try:
extracted = extract_zip_upload(
session,
tenant_id=principal.tenant_id,
owner_type=owner_type,
owner_id=target_owner,
user_id=principal.user.id,
zip_data=data,
folder=path,
campaign_id=campaign_id,
is_admin=_is_admin(principal),
)
session.commit()
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
return FileUploadResponse(files=[_asset_response(session, item.asset, include_shares=True) for item in extracted])
@router.get("/{file_id}", response_model=FileAssetResponse)
def get_file(
file_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal))
return _asset_response(session, asset, include_shares=True)
except FileStorageError as exc:
raise _http_error(exc, not_found=True) from exc
@router.get("/{file_id}/download")
def download_file(
file_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal))
data, _, blob = read_asset_bytes(session, asset)
except FileStorageError as exc:
raise _http_error(exc, not_found=True) from exc
headers = {"Content-Disposition": f'attachment; filename="{asset.filename}"'}
return StreamingResponse(BytesIO(data), media_type=blob.content_type or "application/octet-stream", headers=headers)
@router.delete("/{file_id}", response_model=BulkDeleteResponse)
def delete_file(
file_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
count = soft_delete_assets(session, [asset])
session.commit()
return BulkDeleteResponse(deleted_count=count)
except FileStorageError as exc:
session.rollback()
raise _http_error(exc, not_found=True) from exc
@router.post("/bulk-delete", response_model=BulkDeleteResponse)
def bulk_delete_files(
payload: BulkDeleteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
assets = [
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
for file_id in payload.file_ids
]
count = soft_delete_assets(session, assets)
session.commit()
return BulkDeleteResponse(deleted_count=count)
except FileStorageError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/{file_id}/shares", response_model=FileShareResponse)
def create_share(
file_id: str,
payload: FileShareRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
share = share_file(
session,
tenant_id=principal.tenant_id,
asset=asset,
target_type=payload.target_type,
target_id=payload.target_id,
permission=payload.permission,
user_id=principal.user.id,
)
session.commit()
return FileShareResponse(
id=share.id,
target_type=share.target_type,
target_id=share.target_id,
permission=share.permission,
created_at=share.created_at.isoformat(),
revoked_at=share.revoked_at.isoformat() if share.revoked_at else None,
)
except FileStorageError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/bulk-rename", response_model=RenameResponse)
def bulk_rename(
payload: RenameRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
assets = [
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
for file_id in payload.file_ids
]
previews = [
RenamePreviewItem(
file_id=asset.id,
old_path=asset.display_path,
new_path=normalize_logical_path(build_rename_preview(asset, mode=payload.mode, find=payload.find, replacement=payload.replacement, prefix=payload.prefix, suffix=payload.suffix)),
)
for asset in assets
]
if not payload.dry_run:
by_id = {asset.id: asset for asset in assets}
for item in previews:
rename_asset(by_id[item.file_id], new_path=item.new_path)
session.add(by_id[item.file_id])
session.commit()
return RenameResponse(dry_run=payload.dry_run, items=previews)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/archive.zip")
def download_archive(
payload: ArchiveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
assets = [
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal))
for file_id in payload.file_ids
]
data = create_zip_bytes(session, assets)
except FileStorageError as exc:
raise _http_error(exc) from exc
filename = filename_from_path(normalize_logical_path(payload.filename, fallback_filename="files.zip"))
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return StreamingResponse(BytesIO(data), media_type="application/zip", headers=headers)
@router.post("/resolve-patterns", response_model=PatternResolveResponse)
def resolve_file_patterns(
payload: PatternResolveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
_ensure_list_owner_access(session, principal, payload.owner_type, payload.owner_id)
try:
assets = list_assets_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
owner_type=payload.owner_type,
owner_id=payload.owner_id,
campaign_id=payload.campaign_id,
path_prefix=payload.path_prefix,
is_admin=_is_admin(principal),
)
resolved, unmatched = resolve_patterns(assets, payload.patterns, base_path=payload.path_prefix)
return PatternResolveResponse(
patterns=[PatternMatchResponse(pattern=item.pattern, matches=[_asset_response(session, asset) for asset in item.matches]) for item in resolved],
unmatched=[_asset_response(session, asset) for asset in unmatched] if payload.include_unmatched else [],
)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
raise _http_error(exc) from exc