From a4093e86b16cb5d3494ef077f5c0f658b495530f Mon Sep 17 00:00:00 2001 From: kfj001 Date: Wed, 8 Jul 2026 23:18:07 -0700 Subject: [PATCH] fixes generated image download --- backend/app/api/mobile_builds.py | 49 ++++++++++++++++++++++-- backend/app/auth.py | 25 ++++++++++++ backend/app/config.py | 1 + src/app/admin/admin.component.html | 7 ++-- src/app/admin/admin.component.ts | 41 ++++++++++---------- src/app/services/mobile-build.service.ts | 8 ++-- 6 files changed, 101 insertions(+), 30 deletions(-) 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 @@
{{ artifact.file_name }}
{{ artifact.platform }} • {{ artifact.artifact_type }} • {{ - artifact.sha256.slice(0, 12) }}…
+ artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}
- } @empty { diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index f57497a..5e76f79 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -11,6 +11,7 @@ import { TeamMember } from '../interfaces/team-member'; import { CommunityHighlight } from '../interfaces/community-highlight'; import { Underwriter } from '../interfaces/underwriter'; import { + MobileBuildArtifact, MobileBuildDefaults, MobileBuildRequest, ThemeStatus, @@ -126,8 +127,6 @@ export class AdminComponent implements OnInit { mobileBuildErrorMessage = ''; mobileBuildPreflightIssues: string[] = []; mobileBuildBusy = false; - downloadingArtifactId: number | null = null; - // ── Theme JSON status (mobile build tab) ──────────────────── readonly themeJsonStatus$ = new BehaviorSubject(null); readonly themeJsonLoading$ = new BehaviorSubject(false); @@ -452,24 +451,26 @@ export class AdminComponent implements OnInit { return `${getAppConfig().apiBaseUrl}${path}`; } - async downloadArtifact(artifact: { id: number; file_name: string }): Promise { - try { - this.downloadingArtifactId = artifact.id; - const blob = await firstValueFrom(this.mobileBuildService.downloadArtifact(artifact.id)); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.style.display = 'none'; - a.href = url; - a.download = artifact.file_name; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - } catch { - this.mobileBuildErrorMessage = 'Failed to download artifact.'; - } finally { - this.downloadingArtifactId = null; - } + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const size = bytes / Math.pow(1024, i); + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`; + } + + downloadArtifact(artifact: MobileBuildArtifact): void { + this.mobileBuildService + .getDownloadUrl(artifact.id) + .subscribe({ + next: (res) => { + // Trigger native browser download with the signed URL + window.open(res.download_url, '_blank'); + }, + error: (err) => { + console.error('Failed to get download URL:', err); + }, + }); } // ── Show CRUD ─────────────────────────────────────────── diff --git a/src/app/services/mobile-build.service.ts b/src/app/services/mobile-build.service.ts index 6ca61f2..30cccea 100644 --- a/src/app/services/mobile-build.service.ts +++ b/src/app/services/mobile-build.service.ts @@ -81,10 +81,10 @@ export class MobileBuildService { ); } - downloadArtifact(artifactId: number): Observable { - return this.http.get( - `${this.baseUrl}/artifacts/${artifactId}/download`, - { responseType: 'blob' }, + getDownloadUrl(artifactId: number): Observable<{ download_url: string }> { + return this.http.get<{ download_url: string }>( + `${this.baseUrl}/artifacts/${artifactId}/download-url`, ); } + }