pipeline-sdlc/wwwroot/api/project_file.dspy

99 lines
4.8 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# project_file.dspy - 按项目ID提供产出文件的打开/下载(待办里跨项目直达交付件文件)
# 入参project_id=<项目id>path=<文件路径>(项目目录内相对路径 或 项目目录内的绝对路径),
# download=1 强制下载。
# 安全:仅登录用户;解析后校验文件真实路径必须落在该项目目录内(防路径穿越/跨项目越权)。
import os
from urllib.parse import quote, unquote
from aiohttp.web_fileresponse import FileResponse
def _decode_path(s):
# 前端 bricks.tget 会对已编码的 query 值再 encodeURIComponent 一次(双重编码),
# 服务端框架只解一层 → 残留 %XX 编码态中文 → 项目名/文件名校验失败,
# 误报「非法路径」或「文件不存在」2026-09-16 投标标书 md 在线查看实测根因;
# 下载按钮走 window.open 单层编码不受影响,故只有「在线查看」中招)。
# 循环 unquote 到无 % 为止,兼容任意编码层数(对齐 workspace_view.dspy 的 _decode_id
for _ in range(4):
if '%' not in s:
break
s2 = unquote(s)
if s2 == s:
break
s = s2
return s
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
project_id = _decode_path(((params_kw or {}).get('project_id') or '').strip())
fpath = _decode_path(((params_kw or {}).get('path') or '').strip())
download = ((params_kw or {}).get('download') or '').strip()
if not project_id:
return {"widgettype": "Message", "options": {"title": "错误", "message": "缺少 project_id"}}
if not fpath:
return {"widgettype": "Message", "options": {"title": "错误", "message": "缺少文件路径"}}
dbname = get_module_dbname('pipeline-sdlc')
# 按项目ID解析项目目录不依赖会话上下文并做机构隔离校验
async with DBPools().sqlorContext(dbname) as sor:
precs = await sor.sqlExe(
"SELECT id, name, org_id FROM sd_projects WHERE id=${p}$ LIMIT 1", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if not precs:
return {"widgettype": "Message", "options": {"title": "错误", "message": "项目不存在"}}
proj_org = getattr(precs[0], 'org_id', '0') or '0'
project_dir, workspace_base = await get_project_dir_by_id(sor, project_id)
# 项目空间基准:产线空间层 {space}/——项目输出文件的合法根(新旧结构都在其下:
# 新结构 {space}/projects/{项目名}/,旧平铺 {space}/{项目名}_{id}/
space_dir = ''
if project_dir:
# 新结构 project_dir = {space}/projects/{项目名} → 空间层 = 上两级
if os.path.basename(os.path.dirname(project_dir)) == 'projects':
space_dir = os.path.dirname(os.path.dirname(project_dir))
else:
space_dir = os.path.dirname(project_dir)
if not project_dir or not os.path.isdir(project_dir):
return {"widgettype": "Message", "options": {"title": "错误", "message": "项目目录不可用"}}
# 机构隔离:非超管(0)只能访问本机构项目项目为通用机构0时登录即可见
try:
uorg = await get_userorgid()
except Exception:
uorg = ''
if str(uorg) != '0' and str(proj_org) != '0' and str(uorg) != str(proj_org):
return {"widgettype": "Message", "options": {"title": "无权限", "message": "无权访问其他机构的项目文件"}}
# 路径解析:绝对路径直接用;相对路径拼项目目录
cand = fpath if fpath.startswith('/') else os.path.join(project_dir, fpath)
# 防跨项目越权:真实路径必须落在本项目的产线空间内,且路径中含项目标识
# (项目目录名/项目id/项目名任一)——新结构落在项目目录内,旧平铺落在 {项目名}_{id}/ 内,均满足。
real_proj = os.path.realpath(project_dir)
real_space = os.path.realpath(space_dir) if space_dir else real_proj
real_full = os.path.realpath(cand)
_proj_rec_name = getattr(precs[0], 'name', '') or ''
_proj_idents = [x for x in (os.path.basename(project_dir), project_id, _proj_rec_name) if x]
_path_ok = (real_space and real_full.startswith(real_space + os.sep)) or \
real_full.startswith(real_proj + os.sep)
_ident_ok = any(ix in cand for ix in _proj_idents)
if not _path_ok or not _ident_ok:
return {"widgettype": "Message", "options": {"title": "错误", "message": "非法路径(不在项目空间内)"}}
if not os.path.isfile(real_full):
return {"widgettype": "Message", "options": {"title": "文件不存在",
"message": "文件未找到:" + os.path.basename(cand) + "(可能尚未生成或已删除)"}}
headers = {}
if download:
filename = os.path.basename(real_full)
safe_name = quote(filename)
headers['Content-Disposition'] = 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (filename, safe_name)
return FileResponse(real_full, headers=headers)