fix: dSYM/混淆映射下载缺少认证头导致下载失败,dSYM 远端下载改为签名链接
历史记录点击 dSYM 下载后停留在接口地址本身:window.open 无法带 Authorization 头,接口未认证直接 401,重定向逻辑根本没机会执行。 改为前端先用 authFetch 认证请求,再按返回类型跳转远端地址或保存二 进制文件;远端地址同时改造成与 IPA 一致的 OSS 签名短时链接,避免 未签名地址在私有 bucket 下被拒绝。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1466e24c83
commit
082c487cf8
@@ -8,7 +8,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.background import BackgroundTask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -344,7 +344,19 @@ async def download_dsym(task_id: str, request: Request, user: dict = Depends(get
|
||||
db.commit()
|
||||
|
||||
if not local_exists:
|
||||
return RedirectResponse(task.dsym_url)
|
||||
# dSYM 需要携带认证信息才能访问该接口,浏览器无法直接跳转到本接口的
|
||||
# 302 目标,因此改为返回 JSON 让前端自行发起下载。
|
||||
try:
|
||||
from .config import load_config
|
||||
from ..services.distribution import DistributionError, get_published_dsym_url
|
||||
download_url = get_published_dsym_url(
|
||||
json.loads(task.config_json or "{}"),
|
||||
load_config().get("upload", {}),
|
||||
task.dsym_url,
|
||||
)
|
||||
except (json.JSONDecodeError, DistributionError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
|
||||
return {"url": download_url}
|
||||
|
||||
# .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
|
||||
if os.path.isdir(dsym_path):
|
||||
|
||||
@@ -315,14 +315,7 @@ def delete_published_dsym(build_config: dict, upload_config: dict):
|
||||
deleter(upload_config, [_dsym_remote_path(build_config)])
|
||||
|
||||
|
||||
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
|
||||
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
|
||||
if upload_config.get("mode") != "oss":
|
||||
return published_url
|
||||
|
||||
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
|
||||
if not remote_paths:
|
||||
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
|
||||
def _sign_oss_url(upload_config: dict, remote_path: str) -> str:
|
||||
oss = upload_config.get("oss", {})
|
||||
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
|
||||
if any(not oss.get(key) for key in required):
|
||||
@@ -336,7 +329,29 @@ def get_published_download_url(build_config: dict, upload_config: dict, publishe
|
||||
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
|
||||
oss["endpoint"], oss["bucket_name"], connect_timeout=30,
|
||||
)
|
||||
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
|
||||
return bucket.sign_url("GET", remote_path, DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
|
||||
|
||||
|
||||
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
|
||||
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
|
||||
if upload_config.get("mode") != "oss":
|
||||
return published_url
|
||||
|
||||
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
|
||||
if not remote_paths:
|
||||
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
|
||||
return _sign_oss_url(upload_config, remote_paths[0])
|
||||
|
||||
|
||||
def get_published_dsym_url(build_config: dict, upload_config: dict, dsym_url: str) -> str:
|
||||
"""返回 dSYM 受控下载的短时链接;WebDAV 保持原公开链接。
|
||||
|
||||
dSYM 的远端对象键完全由构建配置决定,直接按命名规则重新计算即可,
|
||||
不需要像 IPA 那样从已保存的下载链接反推。
|
||||
"""
|
||||
if upload_config.get("mode") != "oss":
|
||||
return dsym_url
|
||||
return _sign_oss_url(upload_config, _dsym_remote_path(build_config))
|
||||
|
||||
|
||||
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
||||
|
||||
@@ -309,8 +309,36 @@ onUnmounted(() => {
|
||||
if (logWs) { logWs.close(); logWs = null }
|
||||
})
|
||||
|
||||
const downloadDsym = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/dsym`)
|
||||
const triggerBlobDownload = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename || ''
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const filenameFromDisposition = (res, fallback) => {
|
||||
const cd = res.headers.get('content-disposition') || ''
|
||||
const match = cd.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i)
|
||||
return match ? decodeURIComponent(match[1]) : fallback
|
||||
}
|
||||
|
||||
const downloadDsym = async (taskId) => {
|
||||
try {
|
||||
const res = await authFetch(`/api/tasks/${taskId}/dsym`)
|
||||
if (!res.ok) throw new Error()
|
||||
if ((res.headers.get('content-type') || '').includes('application/json')) {
|
||||
const { url } = await res.json()
|
||||
window.open(url, '_blank')
|
||||
} else {
|
||||
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'dsym.zip'))
|
||||
}
|
||||
} catch {
|
||||
alert('下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadIpa = async (taskId) => {
|
||||
@@ -328,8 +356,14 @@ const downloadIpa = async (taskId) => {
|
||||
}
|
||||
}
|
||||
|
||||
const downloadObfMaps = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||
const downloadObfMaps = async (taskId) => {
|
||||
try {
|
||||
const res = await authFetch(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||
if (!res.ok) throw new Error()
|
||||
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'obfuscation_maps.zip'))
|
||||
} catch {
|
||||
alert('下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId) => {
|
||||
|
||||
@@ -67,15 +67,17 @@ def test_download_dsym_nonexistent_task(client, tmp_config):
|
||||
|
||||
|
||||
def test_download_dsym_remote_fallback(client, tmp_config):
|
||||
"""本地文件已被清理,但保存了远端地址时应重定向到远端"""
|
||||
"""本地文件已被清理,但保存了远端地址时应返回远端下载链接(而非直接重定向,
|
||||
因为该接口需要认证信息,浏览器无法直接跳转到 302 目标)"""
|
||||
task_id = _create_task_with_files(
|
||||
client, tmp_config,
|
||||
dsym_path="/tmp/nonexistent.dSYM",
|
||||
dsym_url="https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip",
|
||||
)
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym", follow_redirects=False)
|
||||
assert resp.status_code in (302, 307)
|
||||
assert resp.headers["location"] == "https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip"
|
||||
with patch("backend.services.distribution.get_published_dsym_url", return_value="https://signed.example.com/app.dSYM.zip?signature=1"):
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["url"] == "https://signed.example.com/app.dSYM.zip?signature=1"
|
||||
|
||||
|
||||
# ---- 混淆映射表下载 ----
|
||||
|
||||
Reference in New Issue
Block a user