File size: 8,738 Bytes
8da71d2
 
 
 
c4a7267
8da71d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4a7267
 
8da71d2
c4a7267
 
 
 
 
 
91f1a17
 
 
 
8da71d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
from __future__ import annotations

import asyncio
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, Request, Response, status
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError

from app.core.exceptions import NotFoundError
from app.generation.models import GenerationJob, GenerationRequest
from app.projects.models import Project, ProjectRenderJob
from app.security.assets import CanonicalAssetNotFoundError
from app.security.models import CanonicalMediaAsset, CanonicalMediaVariant

router = APIRouter(prefix="/v1/assets", tags=["assets"])


class AssetMetadataUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")
    metadata: dict[str, Any] = Field(default_factory=dict)

    @model_validator(mode="after")
    def metadata_is_bounded_json(self) -> "AssetMetadataUpdate":
        try:
            encoded = json.dumps(self.metadata, ensure_ascii=False, allow_nan=False)
        except (TypeError, ValueError) as exc:
            raise ValueError("metadata must contain JSON-compatible values") from exc
        if len(encoded.encode("utf-8")) > 16_384:
            raise ValueError("metadata must not exceed 16 KiB")
        return self


def _identity(request: Request) -> tuple[str, str]:
    context = getattr(request.state, "auth", None)
    if context is None or not context.workspace_id or not context.user_id:
        raise HTTPException(status_code=403, detail="An active workspace membership is required.")
    return context.workspace_id, context.user_id


def _view(record: CanonicalMediaAsset, output_dir: Path) -> dict[str, Any]:
    output_root = output_dir.resolve()
    root = (output_root / record.request_id).resolve()
    path = (root / record.filename).resolve()
    safe = (
        bool(re.fullmatch(r"[0-9a-fA-F-]{16,64}", record.request_id))
        and bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,254}", record.filename))
        and root.parent == output_root
        and path.parent == root
    )
    try:
        available = safe and path.is_file() and path.stat().st_size == record.file_size
    except OSError:
        available = False
    return {
        "id": record.id,
        "workspace_id": record.workspace_id,
        "project_id": record.project_id,
        "request_id": record.request_id,
        "filename": record.filename,
        "mime_type": record.mime_type,
        "file_size": record.file_size,
        "metadata": record.metadata_json or {},
        "file_available": available,
        "created_at": record.created_at,
        "updated_at": record.updated_at,
    }


@router.get("", response_model=dict[str, Any])
async def list_assets(
    request: Request,
    project_id: UUID | None = Query(default=None),
    q: str | None = Query(default=None, min_length=1, max_length=200),
    offset: int = Query(default=0, ge=0),
    limit: int = Query(default=100, ge=1, le=500),
) -> dict[str, Any]:
    workspace_id, user_id = _identity(request)
    container = request.app.state.container
    filters = [CanonicalMediaAsset.workspace_id == workspace_id]
    if project_id is not None:
        filters.append(CanonicalMediaAsset.project_id == str(project_id))
    if q:
        term = "%" + q.strip().replace("%", "\\%").replace("_", "\\_") + "%"
        filters.append(or_(
            CanonicalMediaAsset.filename.ilike(term, escape="\\"),
            CanonicalMediaAsset.mime_type.ilike(term, escape="\\"),
        ))
    async with container.security_database.tenant_session(
        workspace_id=workspace_id, user_id=user_id
    ) as session:
        total = await session.scalar(
            select(func.count()).select_from(CanonicalMediaAsset).where(*filters)
        )
        records = (await session.scalars(
            select(CanonicalMediaAsset)
            .where(*filters)
            .order_by(CanonicalMediaAsset.created_at.desc(), CanonicalMediaAsset.id)
            .offset(offset)
            .limit(limit)
        )).all()
        return {
            "items": [_view(record, container.settings.output_dir) for record in records],
            "total": int(total or 0),
            "offset": offset,
            "limit": limit,
        }


@router.get("/{asset_id}", response_model=dict[str, Any])
async def get_asset(request: Request, asset_id: UUID) -> dict[str, Any]:
    workspace_id, user_id = _identity(request)
    container = request.app.state.container
    try:
        record = await container.assets.get_owned_by_id(
            workspace_id=workspace_id, user_id=user_id, asset_id=str(asset_id)
        )
        path = container.cleanup.resolve_download(record.request_id, record.filename)
        await container.assets.verify_file(record, path)
    except (CanonicalAssetNotFoundError, NotFoundError) as exc:
        raise HTTPException(status_code=404, detail="Asset was not found or failed integrity verification.") from exc
    return _view(record, container.settings.output_dir)


@router.patch("/{asset_id}", response_model=dict[str, Any])
async def update_asset_metadata(
    request: Request, asset_id: UUID, payload: AssetMetadataUpdate
) -> dict[str, Any]:
    workspace_id, user_id = _identity(request)
    container = request.app.state.container
    async with container.security_database.tenant_session(
        workspace_id=workspace_id, user_id=user_id
    ) as session:
        record = await session.scalar(select(CanonicalMediaAsset).where(
            CanonicalMediaAsset.id == str(asset_id),
            CanonicalMediaAsset.workspace_id == workspace_id,
        ))
        if record is None:
            raise HTTPException(status_code=404, detail="Asset was not found in this workspace.")
        record.metadata_json = payload.metadata
        record.updated_at = datetime.now(timezone.utc)
        await session.commit()
        await session.refresh(record)
        return _view(record, container.settings.output_dir)


@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_asset(request: Request, asset_id: UUID) -> Response:
    workspace_id, user_id = _identity(request)
    container = request.app.state.container
    file_path: Path | None = None
    async with container.security_database.tenant_session(
        workspace_id=workspace_id, user_id=user_id
    ) as session:
        record = await session.scalar(
            select(CanonicalMediaAsset)
            .where(
                CanonicalMediaAsset.id == str(asset_id),
                CanonicalMediaAsset.workspace_id == workspace_id,
            )
            .with_for_update()
        )
        if record is None:
            raise HTTPException(status_code=404, detail="Asset was not found in this workspace.")
        if record.project_id is not None:
            raise HTTPException(status_code=409, detail="Detach this asset from its project before deleting it.")
        references = (
            select(GenerationRequest.id).where(GenerationRequest.input_asset_id == record.id).limit(1),
            select(GenerationJob.id).where(GenerationJob.output_asset_id == record.id).limit(1),
            select(Project.thumbnail_asset_id).where(Project.thumbnail_asset_id == record.id).limit(1),
            select(ProjectRenderJob.id).where(ProjectRenderJob.output_asset_id == record.id).limit(1),
            select(CanonicalMediaVariant.id).where(CanonicalMediaVariant.asset_id == record.id).limit(1),
        )
        for statement in references:
            if await session.scalar(statement) is not None:
                raise HTTPException(status_code=409, detail="This asset is referenced by a project or job and cannot be deleted.")
        try:
            file_path = container.cleanup.resolve_download(record.request_id, record.filename)
            await container.assets.verify_file(record, file_path)
        except (CanonicalAssetNotFoundError, NotFoundError):
            file_path = None
        try:
            await session.delete(record)
            await session.commit()
        except IntegrityError as exc:
            await session.rollback()
            raise HTTPException(status_code=409, detail="This asset is still referenced and cannot be deleted.") from exc
    if file_path is not None:
        try:
            await asyncio.to_thread(file_path.unlink, missing_ok=True)
        except OSError:
            # The database record is already gone; the periodic output cleanup
            # reconciles any orphaned file without exposing its filesystem path.
            pass
    return Response(status_code=status.HTTP_204_NO_CONTENT)