- _serve_file 识别 Vite 内容 hash 文件名(-<hash>.<ext>,hash 段须含大写或数字) - hash 产物: public, max-age=259200, immutable(72h,路径随版本变化不影响新版本生效) - 非 hash 的传统资源保持 1h/24h,index.html 仍 no-cache
333 lines
14 KiB
Python
333 lines
14 KiB
Python
import os
|
||
import re
|
||
import mimetypes
|
||
from pathlib import Path
|
||
from django.http import HttpResponse, Http404, JsonResponse, HttpResponseRedirect
|
||
from django.views import View
|
||
from django.conf import settings
|
||
from gvsds.template_engine import render_template
|
||
|
||
TEMPLATE_DIR = Path(settings.BASE_DIR) / "TempLate"
|
||
|
||
#: 构建产物(Vite 输出 <name>-<hash>.<ext>)的强缓存时长:72 小时
|
||
#: 依据:文件名内含内容 hash,内容与名字一一对应;新版本会换新文件名,
|
||
#: 因此不会出现「改了代码但客户端还是旧文件」的问题(index.html 仍为 no-cache)。
|
||
HASHED_ASSET_MAX_AGE = 60 * 60 * 72
|
||
#: 识别构建产物名:-<8 位以上 hash>.<ext>,且 hash 段须含大写字母或数字
|
||
#: (避免误伤 gvs-design-system.css 这类全小写连字符的传统文件名)
|
||
HASHED_ASSET_RE = re.compile(r'-(?=[A-Za-z0-9_-]*[A-Z0-9])[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$')
|
||
|
||
MIME_TYPES = {
|
||
'.js': 'application/javascript',
|
||
'.mjs': 'application/javascript',
|
||
'.css': 'text/css',
|
||
'.png': 'image/png',
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.gif': 'image/gif',
|
||
'.svg': 'image/svg+xml',
|
||
'.ico': 'image/x-icon',
|
||
'.woff': 'font/woff',
|
||
'.woff2': 'font/woff2',
|
||
'.ttf': 'font/ttf',
|
||
'.eot': 'application/vnd.ms-fontobject',
|
||
'.json': 'application/json',
|
||
'.xml': 'application/xml',
|
||
'.html': 'text/html',
|
||
'.htm': 'text/html',
|
||
'.txt': 'text/plain',
|
||
'.pdf': 'application/pdf',
|
||
'.zip': 'application/zip',
|
||
'.mp4': 'video/mp4',
|
||
'.webp': 'image/webp',
|
||
'.map': 'application/json',
|
||
}
|
||
|
||
SUBDOMAIN_CONFIG = {
|
||
'www': {'dir': '', 'root_is_index': True},
|
||
'gerp': {'dir': 'GERP', 'root_is_index': True},
|
||
'ugm': {'dir': '', 'root_is_index': True},
|
||
'api': {'dir': None, 'root_is_index': False},
|
||
'mc': {'dir': 'MC', 'root_is_index': True},
|
||
'3crb': {'dir': 'CCCRB', 'root_is_index': True},
|
||
'ccgo': {'dir': 'CCGO', 'root_is_index': True},
|
||
'invite': {'dir': '', 'root_is_index': True},
|
||
'doc': {'dir': '', 'root_is_index': True},
|
||
'discuss': {'dir': '', 'root_is_index': True},
|
||
'community': {'dir': '', 'root_is_index': True},
|
||
'comm': {'dir': '', 'root_is_index': True},
|
||
't4': {'dir': None, 'root_is_index': False},
|
||
'verify': {'dir': 'Verify', 'root_is_index': True},
|
||
'verifox': {'dir': 'Verifox', 'root_is_index': True},
|
||
'nav': {'dir': 'nav', 'root_is_index': True},
|
||
'history': {'dir': 'history', 'root_is_index': True},
|
||
'cdn': {'dir': 'cdn', 'root_is_index': False, 'root_text': 'GVSDS CDN'},
|
||
'yc8z': {'dir': 'yc8z', 'root_is_index': True},
|
||
'omc': {'dir': 'OMC', 'root_is_index': True},
|
||
'sve': {'dir': 'SVE', 'root_is_index': True},
|
||
'tailwind': {'dir': 'tailwindcss', 'root_is_index': False, 'root_redirect': '/3.4.17'},
|
||
'nanjingmassacre': {'dir': 'NanjingMassacre', 'root_is_index': True},
|
||
'3c': {'dir': 'CreateECentralCourt', 'root_is_index': True},
|
||
'vu': {'dir': None, 'root_is_index': False},
|
||
'links': {'dir': None, 'root_is_index': False},
|
||
'unclear': {'dir': 'Unclear', 'root_is_index': True},
|
||
'old2': {'dir': 'Olds/2', 'root_is_index': True, 'base': 'Olds/2/_base'},
|
||
'old3': {'dir': 'Olds/3', 'root_is_index': True, 'base': 'Olds/3/_base'},
|
||
# Viper 语言官网(Vue 产物在 TempLate/Viper;venom 由 Main.py STATIC_SITES 直接静态托管,
|
||
# 此处登记仅为 dev server / 兜底一致)
|
||
'viper': {'dir': 'Viper', 'root_is_index': True},
|
||
'venom': {'dir': 'Venom', 'root_is_index': True},
|
||
}
|
||
|
||
|
||
def _get_subdomain(request):
|
||
return getattr(request, 'subdomain', 'www') or 'www'
|
||
|
||
|
||
def _get_subdomain_dir(subdomain):
|
||
config = SUBDOMAIN_CONFIG.get(subdomain, {})
|
||
return config.get('dir', '')
|
||
|
||
|
||
#: 专属目录子域未命中时,允许回退到站点根目录的路径前缀
|
||
#: (旧站复用根 /assets;GERP legacy 页面复用根 /pages)
|
||
ROOT_FALLBACK_PREFIXES = ('assets/', 'pages/')
|
||
|
||
|
||
def _resolve_file(subdomain, file_path):
|
||
"""解析子域请求对应的文件
|
||
|
||
专属目录子域(如 gerp → TempLate/GERP):
|
||
1. 先在自己的目录内查找,命中即返回;
|
||
2. 目录存在但未命中时,只允许 ROOT_FALLBACK_PREFIXES 前缀继续回退到站点根目录,
|
||
其余路径(尤其 index.html 等 HTML 入口)一律不回退——否则子域目录被清空的
|
||
瞬间(如旧的「先删后建」部署空窗期)就会命中根目录的 index.html,
|
||
把 gerp.gvsds.com 串台成 www(GVSite)首页;
|
||
3. 专属目录本身不存在(如 SUBDOMAIN_CONFIG 登记了但尚未落盘的 nav/history)
|
||
则沿用旧行为回退根目录,避免误伤这些别名子域。
|
||
"""
|
||
if '..' in file_path.split('/') or '..' in file_path.split(os.sep):
|
||
return None
|
||
subdomain_dir = _get_subdomain_dir(subdomain)
|
||
if subdomain_dir is None:
|
||
return None
|
||
if subdomain_dir:
|
||
site_dir = TEMPLATE_DIR / subdomain_dir
|
||
if site_dir.is_dir():
|
||
candidate = site_dir / file_path
|
||
try:
|
||
candidate.resolve().relative_to(TEMPLATE_DIR.resolve())
|
||
except ValueError:
|
||
return None
|
||
if candidate.is_file():
|
||
return candidate
|
||
# 目录已落盘:只放行共享前缀,避免子域 HTML 入口串到 www 站
|
||
if not file_path.lower().startswith(ROOT_FALLBACK_PREFIXES):
|
||
return None
|
||
candidate = TEMPLATE_DIR / file_path
|
||
try:
|
||
candidate.resolve().relative_to(TEMPLATE_DIR.resolve())
|
||
except ValueError:
|
||
return None
|
||
if candidate.is_file():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def _serve_file(full_path, subdomain=None):
|
||
ext = full_path.suffix.lower()
|
||
content_type = MIME_TYPES.get(ext, mimetypes.guess_type(str(full_path))[0] or 'application/octet-stream')
|
||
|
||
if ext in ('.html', '.htm'):
|
||
rel_path = full_path.relative_to(TEMPLATE_DIR)
|
||
template_name = str(rel_path).replace('\\', '/')
|
||
base = None
|
||
use_base = False
|
||
if subdomain:
|
||
config = SUBDOMAIN_CONFIG.get(subdomain, {})
|
||
base = config.get('base')
|
||
use_base = config.get('use_base', False)
|
||
if base:
|
||
response = render_template(request=None, template_name=template_name, UseBase='false', SubBase=base)
|
||
elif not use_base:
|
||
response = render_template(request=None, template_name=template_name, UseBase='false')
|
||
else:
|
||
response = render_template(request=None, template_name=template_name, UseBase='true')
|
||
# HTML 永不缓存:Vue SPA 的 index.html 引用了带 hash 的资源名,
|
||
# 若被浏览器/代理缓存,会引用已删除的旧资源导致 404、页面加载不全
|
||
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||
return response
|
||
|
||
with open(full_path, 'rb') as f:
|
||
response = HttpResponse(f.read(), content_type=content_type)
|
||
|
||
if HASHED_ASSET_RE.search(full_path.name):
|
||
# Vite 构建产物(如 index-C_-1ws_N.js / style-BWB-O5Ko.css):
|
||
# 文件名含内容 hash,路径随版本变化,可长期强缓存 + immutable 免协商
|
||
response['Cache-Control'] = f'public, max-age={HASHED_ASSET_MAX_AGE}, immutable'
|
||
elif ext in ('.css', '.js', '.mjs', '.json', '.xml', '.txt', '.svg'):
|
||
# 非 hash 命名的传统静态资源(legacy 站点),改动后需较快生效
|
||
response['Cache-Control'] = 'public, max-age=3600'
|
||
elif ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.mp4'):
|
||
response['Cache-Control'] = 'public, max-age=86400'
|
||
|
||
return response
|
||
|
||
|
||
# ============================================================================
|
||
# doc 子域 JSON API(页面由 GVSite SPA 渲染)
|
||
# /API/GetDoc/<uuid> → 文章 JSON
|
||
# /API/GetSerieDirectory/<uuid> → 系列目录 / 单文档标记 JSON
|
||
# ============================================================================
|
||
|
||
|
||
def _doc_api(file_path):
|
||
"""doc 子域 JSON API:API/GetDoc/<uuid> 与 API/GetSerieDirectory/<uuid>。"""
|
||
from gvsds.apps.doc.manager import DocManager
|
||
from gvsdsdk.model_utils import UUIDObj
|
||
|
||
parts = file_path.split('/')
|
||
if len(parts) < 3 or parts[0] != 'API':
|
||
return None
|
||
action = parts[1]
|
||
uuid_text = parts[2].strip('/')
|
||
try:
|
||
uuid_bytes = UUIDObj(uuid_text).bytes
|
||
except (ValueError, TypeError, AttributeError):
|
||
return JsonResponse({'error': 'invalid_uuid'}, status=404)
|
||
|
||
if action == 'GetDoc':
|
||
doc = DocManager.GetDocument(uuid_bytes)
|
||
if doc is None:
|
||
return JsonResponse({'error': 'not_found'}, status=404)
|
||
content = doc.DocContent or ''
|
||
doc_format = doc.DocFormat
|
||
# 数据兜底:内容实为完整 HTML 文档时按 html 渲染(marked 会把内嵌 </style> 拆坏,污染站点样式)
|
||
if doc_format == 'markdown' and content.lstrip().lower().startswith(('<!doctype', '<html')):
|
||
doc_format = 'html'
|
||
return JsonResponse({
|
||
'DocTitle': doc.DocTitle or '未命名文档',
|
||
'DocContent': content,
|
||
'DocFormat': doc_format,
|
||
'DocPermission': 1,
|
||
'DocDecrypt': True,
|
||
})
|
||
|
||
if action == 'GetSerieDirectory':
|
||
serie = DocManager.GetSerie(uuid_bytes)
|
||
if serie is not None:
|
||
docs = DocManager.ListDocumentsBySerie(uuid_bytes)
|
||
chapters = [
|
||
{
|
||
'id': UUIDObj(d.DocUUID).str.replace('-', ''),
|
||
'title': d.DocTitle or '未命名文档',
|
||
'sections': [],
|
||
}
|
||
for d in docs
|
||
]
|
||
return JsonResponse({
|
||
'title': serie.DocSerieName or '未命名系列',
|
||
'document': False,
|
||
'chapters': chapters,
|
||
})
|
||
# 非系列 UUID:单文档模式,返回所属系列的全部文章供侧边栏展示
|
||
doc = DocManager.GetDocument(uuid_bytes)
|
||
if doc is None:
|
||
return JsonResponse({'error': 'not_found'}, status=404)
|
||
chapters = []
|
||
if doc.DocSerieUUID:
|
||
serie_docs = DocManager.ListDocumentsBySerie(doc.DocSerieUUID)
|
||
chapters = [
|
||
{
|
||
'id': UUIDObj(d.DocUUID).str.replace('-', ''),
|
||
'title': d.DocTitle or '未命名文档',
|
||
'sections': [],
|
||
}
|
||
for d in serie_docs
|
||
]
|
||
return JsonResponse({
|
||
'title': doc.DocTitle or '未命名文档',
|
||
'document': True,
|
||
'id': UUIDObj(doc.DocUUID).str.replace('-', ''),
|
||
'chapters': chapters,
|
||
})
|
||
|
||
return None
|
||
|
||
|
||
class SubdomainView(View):
|
||
def get(self, request, file_path=''):
|
||
subdomain = _get_subdomain(request)
|
||
config = SUBDOMAIN_CONFIG.get(subdomain, {})
|
||
|
||
if config.get('dir') is None:
|
||
raise Http404
|
||
|
||
# doc 子域:动态阅读 JSON API(页面由 GVSite SPA 渲染,走下方通用静态/SPA fallback)
|
||
# doc.gvsds.com/API/GetDoc/<uuid> → 文章 JSON
|
||
# doc.gvsds.com/API/GetSerieDirectory/<uuid> → 目录 JSON
|
||
if subdomain == 'doc' and file_path and file_path.startswith('API/'):
|
||
api_resp = _doc_api(file_path)
|
||
if api_resp:
|
||
return api_resp
|
||
|
||
# ugm 子域:未登录则跳转登录页,已登录则跳转 gerp
|
||
if subdomain == 'ugm':
|
||
# JWT Cookie 认证:检查 AccessToken 或 RefreshToken
|
||
has_jwt = request.COOKIES.get('AccessToken') or request.COOKIES.get('RefreshToken')
|
||
if not has_jwt:
|
||
# 未登录 → 跳转 www 登录页
|
||
if file_path and file_path not in ('/', 'login.html', 'register.html'):
|
||
login_url = f"https://www.gvsds.com/login?redirect=https://gerp.gvsds.com/"
|
||
return HttpResponseRedirect(login_url)
|
||
if not file_path or file_path == '/':
|
||
return HttpResponseRedirect('https://www.gvsds.com/login')
|
||
else:
|
||
# 已登录 → 跳转 gerp
|
||
if not file_path or file_path == '/' or file_path == 'index.html':
|
||
return HttpResponseRedirect('https://gerp.gvsds.com/')
|
||
|
||
if not file_path or file_path == '/':
|
||
if config.get('root_redirect'):
|
||
return HttpResponseRedirect(config['root_redirect'])
|
||
if config.get('root_text') is not None:
|
||
return HttpResponse(config['root_text'])
|
||
if config.get('root_is_index'):
|
||
file_path = 'index.html'
|
||
else:
|
||
raise Http404
|
||
|
||
if file_path.endswith('/'):
|
||
file_path += 'index.html'
|
||
|
||
if subdomain == 'tailwind' and not file_path.endswith('.js') and not file_path.endswith('.json'):
|
||
js_path = _resolve_file(subdomain, file_path + '.js')
|
||
if js_path:
|
||
return _serve_file(js_path, subdomain)
|
||
|
||
resolved = _resolve_file(subdomain, file_path)
|
||
if resolved:
|
||
return _serve_file(resolved, subdomain)
|
||
|
||
# www/invite 子域:旧 .html 页面已被 Vue SPA 取代,去掉后缀重定向到 Vue 路由
|
||
# 兼容历史链接(如 /login.html、/gallery.html 等被外部页面/脚本引用的旧地址)
|
||
# 目录请求(/xxx/ 拼成的 /xxx/index.html)不走旧 .html 链接重定向,交给下方 SPA fallback
|
||
if subdomain in ('www', 'invite') and file_path.endswith('.html') and not file_path.endswith('/index.html'):
|
||
new_path = file_path[:-5] or '/'
|
||
redirect_url = new_path
|
||
if request.META.get('QUERY_STRING'):
|
||
redirect_url += '?' + request.META['QUERY_STRING']
|
||
return HttpResponseRedirect(redirect_url)
|
||
|
||
# SPA fallback:前端单页应用(如 GERP Vue)的客户端路由路径(无扩展名)
|
||
# 找不到对应文件时,回退到 index.html,由前端路由处理
|
||
# 目录请求(/xxx/ 被拼成 /xxx/index.html)同样回退 SPA,避免误入 Django 404
|
||
if config.get('root_is_index') and (
|
||
'.' not in os.path.basename(file_path) or file_path.endswith('/index.html')
|
||
):
|
||
index_resolved = _resolve_file(subdomain, 'index.html')
|
||
if index_resolved:
|
||
return _serve_file(index_resolved, subdomain)
|
||
|
||
raise Http404
|