- 新增 gvsds/apps/gerp/apps/<appid>/:talk/mailbox/gitea/oa/ehrm/dingcalc_finance/ product_order/shop/store_value/wallet/enterprise,各应用自带 models/manager/views/urls 与 apps.gerp_manifests(JSON 清单,$schema 指向 apps/gerp_manifests.schema.json) - 删除 app_manifests.py 内联 _LEGACY_MANIFESTS,改为扫描各应用清单文件(未知键/缺必填键即判非法并跳过) - 路由按应用拆:/API/Mail/、/API/Gitea/、/API/Oa/ 由扁平直挂改为 include 子应用 urls.py (reverse() 断言 19 个 name 的路径逐字不变) - AppConfig 移至 gerp_app.py 并改显式点分路径注册,规避 apps/ 目录与 Django <app>.apps 同名冲突 - gerp/models.py 裁剪已搬迁模型(保留 _NewUuidBytes 与平台模型),修复 views/__init__.py 与 oidc.py 导入 - AssetDownloadView:优先按 FileAsset.StoragePath 定位并回退 ./resource 旧布局,修上传成功却下载 404 - 同批落地 Talk 应用后端(app 已迁入 apps/talk,gerp.0046 状态认领迁移) - 注意:app.ini 被 .gitignore 忽略,INSTALLED_APPS 需在部署环境手工同步 gvsds.apps.gerp.gerp_app.GerpConfig
102 lines
3.7 KiB
Python
102 lines
3.7 KiB
Python
import os
|
||
import re
|
||
import json
|
||
import hashlib
|
||
from pathlib import Path
|
||
from django.http import HttpResponse, JsonResponse, FileResponse
|
||
from django.views import View
|
||
from rest_framework.views import APIView
|
||
from rest_framework.response import Response
|
||
from rest_framework.permissions import AllowAny
|
||
|
||
|
||
class IPInfoView(APIView):
|
||
permission_classes = [AllowAny]
|
||
|
||
def get(self, request):
|
||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '')
|
||
ip = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR', '')
|
||
return Response({'code': 0, 'data': {'ip': ip}})
|
||
|
||
|
||
class AssetDownloadView(View):
|
||
"""文件资产下载(按 SHA1)
|
||
|
||
取物理文件的顺序(先新后旧):
|
||
1. FileAsset.StoragePath —— 当前上传链路(/API/Asset/Upload*)的落地位置
|
||
(MEDIA_ROOT/assets/<uploadId>_<原始文件名>)
|
||
2. ./resource/<aa>/<bb>/<cc>/<sha1> —— Flask 期遗留布局,保持向后兼容
|
||
|
||
历史问题:上传写 MEDIA_ROOT/assets,而本视图原先只读 ./resource 布局,
|
||
导致上传成功却下载 404(资产管理页与 Talk 图片/文件消息均受影响)。
|
||
"""
|
||
|
||
def get(self, request, sha1):
|
||
# 验证 sha1 参数格式,防止路径遍历
|
||
if not re.match(r'^[0-9a-fA-F]{40}$', sha1):
|
||
return HttpResponse('Invalid hash', status=400)
|
||
|
||
resolved = _ResolveAssetFile(sha1)
|
||
if resolved is None:
|
||
return JsonResponse({'code': 404, 'msg': '文件不存在'}, status=404)
|
||
|
||
file_path, file_name = resolved
|
||
return FileResponse(open(file_path, 'rb'), as_attachment=True, filename=file_name or sha1)
|
||
|
||
|
||
#: 允许下载的目录白名单(解析后必须落在其中,防路径遍历)
|
||
def _ResolveAssetFile(sha1):
|
||
"""解析资产物理文件:返回 (Path, 下载文件名);找不到返回 None
|
||
|
||
先在 FileAsset 表按 SHA1 查 StoragePath(并校验路径落在允许目录内),
|
||
再回退到 ./resource 分段布局。
|
||
"""
|
||
from django.conf import settings as _settings
|
||
|
||
media_root = Path(getattr(_settings, 'MEDIA_ROOT', '.')).resolve()
|
||
resource_root = Path('./resource').resolve()
|
||
allowed_roots = (media_root, resource_root)
|
||
|
||
def _InAllowed(p):
|
||
try:
|
||
resolved = p.resolve()
|
||
except OSError:
|
||
return None
|
||
for root in allowed_roots:
|
||
try:
|
||
resolved.relative_to(root)
|
||
return resolved
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
# 1) 当前上传链路:FileAsset.StoragePath
|
||
try:
|
||
from gvsds.apps.gerp.models import FileAsset
|
||
|
||
asset = FileAsset.objects.filter(FileSHA1=sha1).first()
|
||
if asset and asset.StoragePath:
|
||
stored = Path(asset.StoragePath)
|
||
for candidate in (stored, (media_root / str(asset.StoragePath).lstrip('/\\'))):
|
||
safe = _InAllowed(candidate)
|
||
if safe is not None and safe.is_file():
|
||
return safe, asset.FileName or sha1
|
||
except Exception:
|
||
# 查库失败不能影响旧布局回退(保持下载可用)
|
||
import logging
|
||
|
||
logging.getLogger(__name__).warning('资产按 StoragePath 定位失败:%s', sha1, exc_info=True)
|
||
|
||
# 2) Flask 期遗留布局:./resource/<aa>/<bb>/<cc>/<sha1>
|
||
legacy = _InAllowed(resource_root / sha1[0:2] / sha1[2:4] / sha1[4:6] / sha1)
|
||
if legacy is not None and legacy.is_file():
|
||
return legacy, sha1
|
||
return None
|
||
|
||
|
||
class HealthCheckView(APIView):
|
||
permission_classes = [AllowAny]
|
||
|
||
def get(self, request):
|
||
return Response({'code': 0, 'msg': 'ok', 'service': 'gvsds-django'})
|