fixes generated image download
This commit is contained in:
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────
|
// ── Show CRUD ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user