diff --git a/backend/app/api/mobile_builds.py b/backend/app/api/mobile_builds.py index 234e9ff..5c60b56 100644 --- a/backend/app/api/mobile_builds.py +++ b/backend/app/api/mobile_builds.py @@ -9,11 +9,18 @@ from pathlib import Path from typing import Literal from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi.security import HTTPAuthorizationCredentials from fastapi.responses import FileResponse from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import get_current_admin_user +from app.auth import ( + AuthBearer, + create_download_token, + decode_token, + get_current_admin_user, + verify_download_token, +) from app.config import settings from app.database import async_session, get_session from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig @@ -834,12 +841,48 @@ async def trigger_mobile_build( return await _hydrate_request(session, request) -@router.get("/artifacts/{artifact_id}/download") -async def download_mobile_build_artifact( +@router.get("/artifacts/{artifact_id}/download-url") +async def get_download_url( artifact_id: int, session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): + """Return a short-lived signed download URL for the artifact.""" + result = await session.execute( + select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id) + ) + row = result.scalar_one_or_none() + if row is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact not found") + + token = create_download_token(artifact_id) + download_url = f"/api/mobile-builds/artifacts/{artifact_id}/download?token={token}" + return {"download_url": download_url} + + +@router.get("/artifacts/{artifact_id}/download") +async def download_mobile_build_artifact( + artifact_id: int, + token: str | None = Query(None), + credentials: HTTPAuthorizationCredentials | None = Depends(AuthBearer(auto_error=False)), + session: AsyncSession = Depends(get_session), +): + """Download an artifact. Accepts either Bearer auth OR a signed token query param.""" + # Authenticate: signed token takes precedence, fall back to Bearer JWT + if token: + verified_id = verify_download_token(token) + if verified_id is None or verified_id != artifact_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired download token") + elif credentials: + payload = decode_token(credentials.credentials) + user_id = int(payload["sub"]) + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None or not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + else: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") + result = await session.execute( select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id) ) diff --git a/backend/app/auth.py b/backend/app/auth.py index 9302091..c7e7d3a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -37,6 +37,31 @@ def create_access_token(user_id: int, is_admin: bool) -> str: return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") +def create_download_token(artifact_id: int) -> str: + """Create a short-lived JWT for downloading a specific artifact.""" + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.DOWNLOAD_TOKEN_MINUTES) + payload = { + "sub": "download", + "artifact_id": artifact_id, + "exp": expire, + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + + +def verify_download_token(token: str) -> int | None: + """Verify a download token and return the artifact_id, or None on failure.""" + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) + if payload.get("sub") != "download": + return None + artifact_id = payload.get("artifact_id") + if not isinstance(artifact_id, int): + return None + return artifact_id + except JWTError: + return None + + def decode_token(token: str) -> dict: """Decode and verify a JWT token. Raises HTTPException 401 on failure.""" try: diff --git a/backend/app/config.py b/backend/app/config.py index 6745010..593bf2b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,6 +13,7 @@ class Settings(BaseSettings): GOOGLE_REDIRECT_URI: str = "http://localhost:4200" JWT_SECRET_KEY: str = "change-me-in-production" JWT_EXPIRE_MINUTES: int = 1440 # 24 hours + DOWNLOAD_TOKEN_MINUTES: int = 10 # short-lived signed download URLs # Visitor stats LOGS_DIR: str = "/logs" diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index c85a737..d5bbd0d 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -616,10 +616,11 @@