gabriel / musehub public
objects.py python
295 lines 10.6 KB
Raw
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 25 days ago
1 """MuseHub object (artifact) route handlers.
2
3 Endpoint summary:
4 GET /repos/{repo_id}/objects — list artifact metadata
5 GET /repos/{repo_id}/objects/{object_id}/content — serve raw artifact bytes
6 GET /repos/{repo_id}/blob/{ref}/{path} — blob metadata + text content for UI
7 GET /repos/{repo_id}/tree/{ref} — list repo root directory at a ref
8 GET /repos/{repo_id}/tree/{ref}/{path} — list subdirectory at a ref
9
10 All endpoints require a valid MSign token.
11 """
12
13 import logging
14 import mimetypes
15 from pathlib import Path
16
17 from fastapi import APIRouter, Depends, HTTPException, status
18 from fastapi.responses import Response
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from musehub.api.validation import FilePathParam, SlugParam
22 from musehub.auth.dependencies import TokenClaims, optional_token
23 from musehub.db import get_db
24 from musehub.models.musehub import BlobMetaResponse, ObjectMetaListResponse, TreeListResponse
25 from musehub.types.json_types import StrDict
26 from musehub.services import musehub_repository
27 from musehub.storage.backends import get_backend as _get_storage_backend
28
29 logger = logging.getLogger(__name__)
30
31 router = APIRouter()
32
33 # Additional MIME types not always in the system mimetypes database
34 _EXTRA_MIME: StrDict = {
35 ".webp": "image/webp",
36 }
37
38
39 def _content_type(path: str) -> str:
40 """Resolve MIME type from path extension; fall back to octet-stream."""
41 ext = Path(path).suffix.lower()
42 if ext in _EXTRA_MIME:
43 return _EXTRA_MIME[ext]
44 guessed, _ = mimetypes.guess_type(path)
45 return guessed or "application/octet-stream"
46
47
48 # Maximum text file size to embed in BlobMetaResponse.content_text (256 KB).
49 _MAX_TEXT_EMBED_BYTES = 256 * 1024
50
51 _FILE_TYPE_MAP: StrDict = {
52 ".webp": "image",
53 ".png": "image",
54 ".jpg": "image",
55 ".jpeg": "image",
56 ".json": "json",
57 ".xml": "xml",
58 }
59
60
61 def _detect_file_type(path: str) -> str:
62 """Return a rendering hint based on file extension.
63
64 Values: 'json' | 'image' | 'xml' | 'other'
65 """
66 ext = Path(path).suffix.lower()
67 return _FILE_TYPE_MAP.get(ext, "other")
68
69
70 @router.get(
71 "/repos/{repo_id}/blob/{ref}/{path:path}",
72 response_model=BlobMetaResponse,
73 operation_id="getBlobMeta",
74 summary="Get blob metadata and optional text content for the file viewer",
75 )
76 async def get_blob_meta(
77 repo_id: str,
78 ref: str,
79 path: FilePathParam,
80 db: AsyncSession = Depends(get_db),
81 claims: TokenClaims | None = Depends(optional_token),
82 ) -> BlobMetaResponse:
83 """Return metadata for a single file in the blob viewer.
84
85 The response includes the file's size, SHA, creation timestamp, and
86 rendering hint (``file_type``). For text-based files (JSON, XML) up to
87 256 KB, ``content_text`` is populated so the viewer can render them
88 inline without a second request. Binary and oversized files omit
89 ``content_text``; consumers should stream bytes from ``raw_url`` instead.
90
91 The ``ref`` parameter accepts branch names or commit SHAs and is used
92 for URL construction (raw_url). Object resolution always returns the
93 most-recently-pushed object at ``path``, consistent with the raw and
94 tree endpoints at MVP scope.
95
96 Returns 404 if the repo is not found or no object exists at that path.
97 Returns 401 if the repo is private and no valid token is supplied.
98 """
99 repo = await musehub_repository.get_repo(db, repo_id)
100 if repo is None:
101 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
102 if repo.visibility != "public" and claims is None:
103 raise HTTPException(
104 status_code=status.HTTP_401_UNAUTHORIZED,
105 detail="Authentication required to access private repos.",
106 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
107 )
108
109 file_meta = await musehub_repository.get_file_at_ref(db, repo_id, ref, path)
110 if file_meta is None:
111 raise HTTPException(
112 status_code=status.HTTP_404_NOT_FOUND,
113 detail=f"No object at path '{path}' in ref '{ref}'",
114 )
115
116 object_id = str(file_meta["object_id"])
117 norm_path = str(file_meta["path"])
118 file_type = _detect_file_type(norm_path)
119 raw_url = f"/repos/{repo_id}/raw/{ref}/{path}"
120
121 obj_row = await musehub_repository.get_object_row(db, repo_id, object_id)
122 if obj_row is None:
123 raise HTTPException(
124 status_code=status.HTTP_404_NOT_FOUND,
125 detail=f"Object '{object_id}' not found in storage",
126 )
127
128 storage = _get_storage_backend()
129 raw_bytes = await storage.get(object_id)
130 size_bytes = len(raw_bytes) if raw_bytes is not None else obj_row.size_bytes
131
132 content_text: str | None = None
133 if file_type in ("json", "xml") and raw_bytes is not None and size_bytes <= _MAX_TEXT_EMBED_BYTES:
134 try:
135 content_text = raw_bytes.decode("utf-8", errors="replace")
136 except Exception:
137 logger.warning("⚠️ Could not decode text content for blob %s", object_id)
138
139 return BlobMetaResponse(
140 object_id=object_id,
141 path=norm_path,
142 filename=Path(norm_path).name,
143 size_bytes=size_bytes,
144 sha=object_id,
145 created_at=obj_row.created_at,
146 raw_url=raw_url,
147 file_type=file_type,
148 content_text=content_text,
149 )
150
151
152 @router.get(
153 "/repos/{repo_id}/objects",
154 response_model=ObjectMetaListResponse,
155 operation_id="listObjects",
156 summary="List artifact metadata for a MuseHub repo",
157 )
158 async def list_objects(
159 repo_id: str,
160 db: AsyncSession = Depends(get_db),
161 claims: TokenClaims | None = Depends(optional_token),
162 ) -> ObjectMetaListResponse:
163 """Return metadata (path, size, object_id) for all objects in the repo.
164
165 Binary content is excluded from this response — use the ``/content``
166 sub-resource to download individual artifacts. Results are ordered by path.
167 Returns 404 if the repo does not exist.
168 """
169 repo = await musehub_repository.get_repo(db, repo_id)
170 if repo is None:
171 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
172 if repo.visibility != "public" and claims is None:
173 raise HTTPException(
174 status_code=status.HTTP_401_UNAUTHORIZED,
175 detail="Authentication required to access private repos.",
176 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
177 )
178 objects = await musehub_repository.list_objects(db, repo_id)
179 return ObjectMetaListResponse(objects=objects)
180
181
182 @router.get(
183 "/repos/{repo_id}/objects/{object_id}/content",
184 operation_id="getObjectContent",
185 summary="Download raw artifact bytes",
186 )
187 async def get_object_content(
188 repo_id: str,
189 object_id: str,
190 db: AsyncSession = Depends(get_db),
191 claims: TokenClaims | None = Depends(optional_token),
192 ) -> Response:
193 """Return the raw bytes of a stored artifact from the blob store.
194
195 Content-Type is inferred from the stored ``path`` extension
196 (.webp → image/webp).
197 Returns 404 if the repo or object is not found, or 410 if the bytes
198 are not present in the blob store.
199 """
200 repo = await musehub_repository.get_repo(db, repo_id)
201 if repo is None:
202 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
203 if repo.visibility != "public" and claims is None:
204 raise HTTPException(
205 status_code=status.HTTP_401_UNAUTHORIZED,
206 detail="Authentication required to access private repos.",
207 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
208 )
209 obj = await musehub_repository.get_object_row(db, repo_id, object_id)
210 if obj is None:
211 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
212
213 data = await _get_storage_backend().get(object_id)
214 if data is None:
215 logger.warning("⚠️ Object %s exists in DB but not in blob store", object_id)
216 raise HTTPException(
217 status_code=status.HTTP_410_GONE,
218 detail="Object has been removed from storage",
219 )
220
221 filename = Path(obj.path).name
222 media_type = _content_type(obj.path)
223 return Response(
224 content=data,
225 media_type=media_type,
226 headers={"Content-Disposition": f'attachment; filename="{filename}"'},
227 )
228
229
230
231
232
233
234 @router.get(
235 "/repos/{repo_id}/tree/{ref_and_path:path}",
236 response_model=TreeListResponse,
237 summary="List a directory of a Muse repo at a given ref and optional sub-path",
238 )
239 async def list_tree(
240 repo_id: str,
241 ref_and_path: str,
242 owner: SlugParam,
243 repo_slug: SlugParam,
244 db: AsyncSession = Depends(get_db),
245 claims: TokenClaims | None = Depends(optional_token),
246 ) -> TreeListResponse:
247 """Return a directory listing for the given ref and optional sub-path.
248
249 Branch names may contain slashes (e.g. ``feat/my-thing``). This endpoint
250 accepts the full tail after ``/tree/`` as ``ref_and_path`` and resolves the
251 boundary between the ref and the sub-path by fetching all known branch names
252 and selecting the longest prefix match — the same strategy as the UI route.
253
254 Query params ``owner`` and ``repo_slug`` are passed through for breadcrumb
255 generation; ``repo_id`` is the authoritative key.
256
257 Returns entries sorted directories-first, then files, both alphabetically.
258 """
259 repo = await musehub_repository.get_repo(db, repo_id)
260 if repo is None:
261 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
262 if repo.visibility != "public" and claims is None:
263 raise HTTPException(
264 status_code=status.HTTP_401_UNAUTHORIZED,
265 detail="Authentication required to access private repos.",
266 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
267 )
268
269 # Disambiguate ref vs sub-path using longest branch-name prefix match.
270 branches = await musehub_repository.list_branches(db, repo_id)
271 branch_names: list[str] = sorted(
272 (b.name for b in branches), key=len, reverse=True
273 )
274 ref = ref_and_path
275 dir_path = ""
276 for name in branch_names:
277 if ref_and_path == name:
278 ref = name
279 dir_path = ""
280 break
281 if ref_and_path.startswith(f"{name}/"):
282 ref = name
283 dir_path = ref_and_path[len(name) + 1:]
284 break
285
286 ref_valid = await musehub_repository.resolve_ref_for_tree(db, repo_id, ref)
287 if not ref_valid:
288 raise HTTPException(
289 status_code=status.HTTP_404_NOT_FOUND,
290 detail=f"Ref '{ref}' not found in repo",
291 )
292 resolved_ref = (
293 await musehub_repository.resolve_head_ref(db, repo_id) if ref == "HEAD" else ref
294 )
295 return await musehub_repository.list_tree(db, repo_id, owner, repo_slug, resolved_ref, dir_path)
File History 1 commit
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 25 days ago