fixes generated image download

This commit is contained in:
2026-07-08 23:18:07 -07:00
parent 57198cd671
commit a4093e86b1
6 changed files with 101 additions and 30 deletions
+46 -3
View File
@@ -9,11 +9,18 @@ from pathlib import Path
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.security import HTTPAuthorizationCredentials
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession 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.config import settings
from app.database import async_session, get_session from app.database import async_session, get_session
from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig
@@ -834,12 +841,48 @@ async def trigger_mobile_build(
return await _hydrate_request(session, request) return await _hydrate_request(session, request)
@router.get("/artifacts/{artifact_id}/download") @router.get("/artifacts/{artifact_id}/download-url")
async def download_mobile_build_artifact( async def get_download_url(
artifact_id: int, artifact_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user), _: 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( result = await session.execute(
select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id) select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id)
) )
+25
View File
@@ -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") 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: def decode_token(token: str) -> dict:
"""Decode and verify a JWT token. Raises HTTPException 401 on failure.""" """Decode and verify a JWT token. Raises HTTPException 401 on failure."""
try: try:
+1
View File
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
GOOGLE_REDIRECT_URI: str = "http://localhost:4200" GOOGLE_REDIRECT_URI: str = "http://localhost:4200"
JWT_SECRET_KEY: str = "change-me-in-production" JWT_SECRET_KEY: str = "change-me-in-production"
JWT_EXPIRE_MINUTES: int = 1440 # 24 hours JWT_EXPIRE_MINUTES: int = 1440 # 24 hours
DOWNLOAD_TOKEN_MINUTES: int = 10 # short-lived signed download URLs
# Visitor stats # Visitor stats
LOGS_DIR: str = "/logs" LOGS_DIR: str = "/logs"
+4 -3
View File
@@ -616,10 +616,11 @@
<div> <div>
<strong>{{ artifact.file_name }}</strong> <strong>{{ artifact.file_name }}</strong>
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{ <div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
artifact.sha256.slice(0, 12) }}…</div> artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}</div>
</div> </div>
<button class="btn-icon btn-edit" (click)="downloadArtifact(artifact)" [disabled]="downloadingArtifactId === artifact.id"> <button class="btn-icon btn-edit"
{{ downloadingArtifactId === artifact.id ? 'Downloading…' : 'Download' }} (click)="downloadArtifact(artifact)">
Download
</button> </button>
</div> </div>
} @empty { } @empty {
+21 -20
View File
@@ -11,6 +11,7 @@ import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight'; import { CommunityHighlight } from '../interfaces/community-highlight';
import { Underwriter } from '../interfaces/underwriter'; import { Underwriter } from '../interfaces/underwriter';
import { import {
MobileBuildArtifact,
MobileBuildDefaults, MobileBuildDefaults,
MobileBuildRequest, MobileBuildRequest,
ThemeStatus, ThemeStatus,
@@ -126,8 +127,6 @@ export class AdminComponent implements OnInit {
mobileBuildErrorMessage = ''; mobileBuildErrorMessage = '';
mobileBuildPreflightIssues: string[] = []; mobileBuildPreflightIssues: string[] = [];
mobileBuildBusy = false; mobileBuildBusy = false;
downloadingArtifactId: number | null = null;
// ── Theme JSON status (mobile build tab) ──────────────────── // ── Theme JSON status (mobile build tab) ────────────────────
readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null); readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null);
readonly themeJsonLoading$ = new BehaviorSubject<boolean>(false); readonly themeJsonLoading$ = new BehaviorSubject<boolean>(false);
@@ -452,24 +451,26 @@ export class AdminComponent implements OnInit {
return `${getAppConfig().apiBaseUrl}${path}`; return `${getAppConfig().apiBaseUrl}${path}`;
} }
async downloadArtifact(artifact: { id: number; file_name: string }): Promise<void> { formatFileSize(bytes: number): string {
try { if (bytes === 0) return '0 B';
this.downloadingArtifactId = artifact.id; const units = ['B', 'KB', 'MB', 'GB'];
const blob = await firstValueFrom(this.mobileBuildService.downloadArtifact(artifact.id)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
const url = window.URL.createObjectURL(blob); const size = bytes / Math.pow(1024, i);
const a = document.createElement('a'); return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`;
a.style.display = 'none'; }
a.href = url;
a.download = artifact.file_name; downloadArtifact(artifact: MobileBuildArtifact): void {
document.body.appendChild(a); this.mobileBuildService
a.click(); .getDownloadUrl(artifact.id)
window.URL.revokeObjectURL(url); .subscribe({
document.body.removeChild(a); next: (res) => {
} catch { // Trigger native browser download with the signed URL
this.mobileBuildErrorMessage = 'Failed to download artifact.'; window.open(res.download_url, '_blank');
} finally { },
this.downloadingArtifactId = null; error: (err) => {
} console.error('Failed to get download URL:', err);
},
});
} }
// ── Show CRUD ─────────────────────────────────────────── // ── Show CRUD ───────────────────────────────────────────
+4 -4
View File
@@ -81,10 +81,10 @@ export class MobileBuildService {
); );
} }
downloadArtifact(artifactId: number): Observable<Blob> { getDownloadUrl(artifactId: number): Observable<{ download_url: string }> {
return this.http.get( return this.http.get<{ download_url: string }>(
`${this.baseUrl}/artifacts/${artifactId}/download`, `${this.baseUrl}/artifacts/${artifactId}/download-url`,
{ responseType: 'blob' },
); );
} }
} }