]> git.ipfire.org Git - pbs.git/commitdiff
api: Implement downloading files from packages
authorMichael Tremer <michael.tremer@ipfire.org>
Mon, 7 Jul 2025 17:44:05 +0000 (17:44 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Mon, 7 Jul 2025 17:44:05 +0000 (17:44 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
src/api/packages.py
src/buildservice/packages.py

index b5af79745891572f2ac654a0a74fc64036fd7d42..8e75f1dd10749da0d842b443d7d24daefc4bc888 100644 (file)
@@ -18,6 +18,7 @@
 #                                                                             #
 ###############################################################################
 
+import asyncio
 import fastapi
 import pydantic
 
@@ -25,6 +26,7 @@ import pydantic
 from uuid import UUID
 
 from . import apiv1
+from . import app
 from . import backend
 
 from ..packages import Package, File
@@ -35,7 +37,7 @@ router = fastapi.APIRouter(
        tags=["Packages"],
 )
 
-async def get_package_by_uuid(uuid: UUID) -> Package:
+async def get_package_by_uuid(uuid: UUID) -> Package | None:
        """
                Fetches a package by its UUID
        """
@@ -88,5 +90,53 @@ async def get_filelist_by_uuid(
        # Return the entire filelist
        return [file async for file in await package.get_files()]
 
+
+@app.get("/packages/{uuid:uuid}/download/{path:path}", include_in_schema=False)
+async def download_file(
+       path: str,
+       package: Package = fastapi.Depends(get_package_by_uuid),
+) -> fastapi.responses.StreamingResponse:
+       # Fail if the package could not be found
+       if not package:
+               raise fastapi.HTTPException(404, "Could not find package")
+
+       # Put the leading slash back into the path
+       path = "/%s" % path
+
+       # Fetch the file
+       file = await package.get_file(path)
+       if not file:
+               raise fastapi.HTTPException(404, "Could not find file %s in %s" % (path, package))
+
+       # XXX Check if this is actually downloadable
+
+       headers = {
+               "Content-Disposition" : "attachment; filename=%s" % file.basename,
+
+               # XXX StreamingResponse does not allow us to set a Content-Length header
+               # because starlette is getting very confused about how much data is to be
+               # sent or has been sent already.
+               #"Content-Length"      : "%s" % file.size,
+
+               # This content should not be indexed
+               "X-Robots-Tag"        : "noindex",
+       }
+
+       # Create a helper function that will stream the file chunk by chunk
+       async def stream():
+               f = await file.open()
+
+               while True:
+                       chunk = await asyncio.to_thread(f.read, 128 * 1024)
+                       if not chunk:
+                               break
+
+                       yield chunk
+
+       return fastapi.responses.StreamingResponse(
+               stream(), media_type=file.mimetype, headers=headers,
+       )
+
+
 # Add everything to the APIv1
 apiv1.include_router(router)
index 7a0b6ebd6e58d981f956e9d6424b252db570759c..4568b424a8c3e66aaee52fd68519ee1642ae5bee 100644 (file)
@@ -656,6 +656,12 @@ class File(sqlmodel.SQLModel, table=True):
 
        path: str = sqlmodel.Field(primary_key=True)
 
+       # Basename
+
+       @property
+       def basename(self):
+               return os.path.basename(self.path)
+
        # Size
 
        size: int