fixes generated image download
This commit is contained in:
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user