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:
shenlei
2026-08-26 16:36:36 +09:00
co-authored by Claude Sonnet 5
parent 1466e24c83
commit 082c487cf8
4 changed files with 82 additions and 19 deletions
+14 -2
View File
@@ -8,7 +8,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -344,7 +344,19 @@ async def download_dsym(task_id: str, request: Request, user: dict = Depends(get
db.commit() db.commit()
if not local_exists: 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 再下载 # .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
if os.path.isdir(dsym_path): if os.path.isdir(dsym_path):
+24 -9
View File
@@ -315,14 +315,7 @@ def delete_published_dsym(build_config: dict, upload_config: dict):
deleter(upload_config, [_dsym_remote_path(build_config)]) deleter(upload_config, [_dsym_remote_path(build_config)])
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str: def _sign_oss_url(upload_config: dict, remote_path: 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 文件路径,无法生成受控下载链接")
oss = upload_config.get("oss", {}) oss = upload_config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"] required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required): 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"]), oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"], oss["bucket_name"], connect_timeout=30, 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): def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
+38 -4
View File
@@ -309,8 +309,36 @@ onUnmounted(() => {
if (logWs) { logWs.close(); logWs = null } if (logWs) { logWs.close(); logWs = null }
}) })
const downloadDsym = (taskId) => { const triggerBlobDownload = (blob, filename) => {
window.open(`/api/tasks/${taskId}/dsym`) 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) => { const downloadIpa = async (taskId) => {
@@ -328,8 +356,14 @@ const downloadIpa = async (taskId) => {
} }
} }
const downloadObfMaps = (taskId) => { const downloadObfMaps = async (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`) 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) => { const deleteTask = async (taskId) => {
+6 -4
View File
@@ -67,15 +67,17 @@ def test_download_dsym_nonexistent_task(client, tmp_config):
def test_download_dsym_remote_fallback(client, tmp_config): def test_download_dsym_remote_fallback(client, tmp_config):
"""本地文件已被清理,但保存了远端地址时应重定向到远端""" """本地文件已被清理,但保存了远端地址时应返回远端下载链接(而非直接重定向,
因为该接口需要认证信息,浏览器无法直接跳转到 302 目标)"""
task_id = _create_task_with_files( task_id = _create_task_with_files(
client, tmp_config, client, tmp_config,
dsym_path="/tmp/nonexistent.dSYM", dsym_path="/tmp/nonexistent.dSYM",
dsym_url="https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip", 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) with patch("backend.services.distribution.get_published_dsym_url", return_value="https://signed.example.com/app.dSYM.zip?signature=1"):
assert resp.status_code in (302, 307) resp = client.get(f"/api/tasks/{task_id}/dsym")
assert resp.headers["location"] == "https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip" assert resp.status_code == 200
assert resp.json()["url"] == "https://signed.example.com/app.dSYM.zip?signature=1"
# ---- 混淆映射表下载 ---- # ---- 混淆映射表下载 ----