Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/fileflash/core/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ def get_admin_storage_service(
def get_admin_files_service(
db: AsyncSession = Depends(get_db),
event_publisher: InProcessAuthEventPublisher = Depends(get_event_publisher),
storage: MinioObjectStorageClient = Depends(get_object_storage),
) -> AdminFilesService:
return AdminFilesService(db=db, publisher=event_publisher)
return AdminFilesService(db=db, publisher=event_publisher, storage=storage)


def get_admin_moderation_service(
Expand Down
29 changes: 29 additions & 0 deletions app/src/fileflash/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,32 @@ def decode_file_preview_token(token: str, settings: Settings) -> dict[str, Any]:
if token_type != "file_preview" or scope != "file.preview":
raise jwt.InvalidTokenError("Invalid token type")
return payload


def create_admin_file_preview_token(
*,
admin_user_id: int,
file_id: int,
settings: Settings,
expires_at: datetime,
) -> str:
now = datetime.now(UTC)
payload: dict[str, Any] = {
"sub": str(admin_user_id),
"typ": "admin_file_preview",
"scope": "admin.file.preview",
"fileId": str(file_id),
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
"jti": str(uuid.uuid4()),
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_admin_file_preview_token(token: str, settings: Settings) -> dict[str, Any]:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
token_type = payload.get("typ")
scope = payload.get("scope")
if token_type != "admin_file_preview" or scope != "admin.file.preview":
raise jwt.InvalidTokenError("Invalid token type")
return payload
Empty file.
90 changes: 87 additions & 3 deletions app/src/fileflash/routers/admin_files.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
from __future__ import annotations

from fastapi import APIRouter, Depends
from datetime import UTC, datetime, timedelta
from urllib.parse import urlencode

from ..core.deps import get_admin_files_service, require_admin
from ..core.errors import api_success
from fastapi import APIRouter, Depends, Header, Query, Request
from fastapi.responses import StreamingResponse
from jwt import InvalidTokenError

from ..core.deps import get_admin_files_service, get_settings_dep, require_admin
from ..core.errors import ApiError, api_success
from ..core.security import create_admin_file_preview_token, decode_admin_file_preview_token
from ..core.settings import Settings
from ..models.tables_identity import User
from ..schemas.admin.files import ListAdminFilesQuery
from ..schemas.file import FilePreviewUrlResponse
from ..services.admin.files import AdminFilesService

router = APIRouter(prefix="/admin/files", tags=["admin"])
Expand All @@ -21,6 +29,82 @@ async def list_admin_files(
return api_success(data=data.model_dump(by_alias=True), message="Files fetched")


@router.get("/{file_id}")
async def get_admin_file_detail(
file_id: int,
_: User = Depends(require_admin),
service: AdminFilesService = Depends(get_admin_files_service),
):
result = await service.get_file_detail(file_id=file_id)
return api_success(data=result.model_dump(by_alias=True), message="File audit detail fetched")


@router.get("/{file_id}/preview")
async def preview_admin_file(
file_id: int,
range_header: str | None = Header(default=None, alias="Range"),
_: User = Depends(require_admin),
service: AdminFilesService = Depends(get_admin_files_service),
):
result = await service.get_preview_stream(file_id=file_id, range_header=range_header)
return StreamingResponse(
result.stream,
media_type=result.content_type,
headers=result.headers,
status_code=result.status_code,
)


@router.post("/{file_id}/preview-url")
async def create_admin_file_preview_url(
file_id: int,
request: Request,
admin: User = Depends(require_admin),
service: AdminFilesService = Depends(get_admin_files_service),
settings: Settings = Depends(get_settings_dep),
):
await service.get_file_detail(file_id=file_id)
expires_at = datetime.now(UTC) + timedelta(seconds=settings.file_preview_url_ttl_seconds)
token = create_admin_file_preview_token(
admin_user_id=int(admin.user_id),
file_id=file_id,
settings=settings,
expires_at=expires_at,
)
stream_url = str(request.url_for("admin_preview_file_stream", file_id=str(file_id)))
result = FilePreviewUrlResponse(
url=f"{stream_url}?{urlencode({'token': token})}",
expires_at=expires_at,
)
return api_success(data=result.model_dump(by_alias=True), message="Preview URL created")


@router.get("/{file_id}/preview-stream", name="admin_preview_file_stream")
async def preview_admin_file_stream(
file_id: int,
token: str = Query(..., min_length=1),
range_header: str | None = Header(default=None, alias="Range"),
service: AdminFilesService = Depends(get_admin_files_service),
settings: Settings = Depends(get_settings_dep),
):
try:
payload = decode_admin_file_preview_token(token, settings)
token_file_id = str(payload["fileId"])
except (InvalidTokenError, KeyError, ValueError):
raise ApiError(status_code=401, code=401, message="Invalid or expired preview token") from None

if token_file_id != str(file_id):
raise ApiError(status_code=403, code=403, message="Preview token does not match file")

result = await service.get_preview_stream(file_id=file_id, range_header=range_header)
return StreamingResponse(
result.stream,
media_type=result.content_type,
headers=result.headers,
status_code=result.status_code,
)


@router.post("/{file_id}/rescan")
async def rescan_admin_file(
file_id: int,
Expand Down
14 changes: 13 additions & 1 deletion app/src/fileflash/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@
UpdateAgentSkillRequest,
)
from .common import ApiResponse, CamelModel, PageQuery, PaginatedData, PaginationMeta
from .file import (
from .admin.files import (
AdminFileAuditDetail,
AdminFileAuditItem,
AdminFileAuditOwner,
AdminFileLatestScan,
ListAdminFilesQuery,
RescanResponse,
)
from .file import (
BatchDownloadRequest,
BatchMoveItemResult,
BatchFilesRequest,
Expand Down Expand Up @@ -150,7 +157,10 @@
"ActivityItem",
"AddGroupMemberRequest",
"AddGroupMemberResponse",
"AdminFileAuditDetail",
"AdminFileAuditItem",
"AdminFileAuditOwner",
"AdminFileLatestScan",
"ApiResponse",
"BatchFilesRequest",
"BatchFilesResponse",
Expand Down Expand Up @@ -200,6 +210,7 @@
"LoginRequest",
"LogItem",
"LogsList",
"ListAdminFilesQuery",
"MarkAllAsReadResponse",
"MarkAsReadResponse",
"MergeChunksRequest",
Expand All @@ -226,6 +237,7 @@
"RenameFileRequest",
"RenameFolderRequest",
"RescanAdminFileResponse",
"RescanResponse",
"ResolveViolationResponse",
"ResetPasswordRequest",
"RestoreRecycleItemRequest",
Expand Down
41 changes: 39 additions & 2 deletions app/src/fileflash/schemas/admin/files.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,55 @@
from __future__ import annotations

from datetime import datetime
from typing import Literal
from typing import Any, Literal

from ..common import CamelModel, PageQuery

VirusStatus = Literal["clean", "pending", "flagged"]


class AdminFileLatestScan(CamelModel):
scan_type: str
scan_result: str
virus_status: VirusStatus
scanned_at: datetime
details: dict[str, Any] | None = None


class AdminFileAuditItem(CamelModel):
id: str
object_id: str
name: str
size: int
mime_type: str
hash: str
virus_status: VirusStatus
is_shared: bool
owner_name: str
upload_count: int
owner_count: int
scanned_at: datetime | None = None
updated_at: datetime
created_at: datetime


class AdminFileAuditOwner(CamelModel):
user_id: str
username: str
email: str
file_count: int
first_uploaded_at: datetime
last_uploaded_at: datetime


class AdminFileAuditDetail(AdminFileAuditItem):
object_hash: str | None = None
hash_algorithm: str
storage_status: str
latest_scan: AdminFileLatestScan | None = None
owners: list[AdminFileAuditOwner]


class ListAdminFilesQuery(PageQuery):
search: str | None = None
virus_status: VirusStatus | None = None
Expand All @@ -36,4 +65,12 @@ class RescanResponse(CamelModel):
scanned_at: datetime


__all__ = ["AdminFileAuditItem", "ListAdminFilesQuery", "RescanResponse", "VirusStatus"]
__all__ = [
"AdminFileAuditDetail",
"AdminFileAuditItem",
"AdminFileAuditOwner",
"AdminFileLatestScan",
"ListAdminFilesQuery",
"RescanResponse",
"VirusStatus",
]
Loading
Loading