# 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)