feat: redesign metadata as KV store, add custom properties and WOPI Discovery
Some checks failed
Test / test (push) Failing after 2m32s

Replace one-to-one FileMetadata table with flexible ObjectMetadata KV pairs,
add custom property definitions, WOPI Discovery auto-configuration, and
per-extension action URL support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:21:22 +08:00
parent 743a2c9d65
commit bcb0a9b322
17 changed files with 1943 additions and 471 deletions

185
service/wopi/__init__.py Normal file
View File

@@ -0,0 +1,185 @@
"""
WOPI Discovery 服务模块
解析 WOPI 服务端Collabora / OnlyOffice 等)的 Discovery XML
提取支持的文件扩展名及对应的编辑器 URL 模板。
参考Cloudreve pkg/wopi/discovery.go 和 pkg/wopi/wopi.go
"""
import xml.etree.ElementTree as ET
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from loguru import logger as l
# WOPI URL 模板中已知的查询参数占位符及其替换值
# 值为 None 表示删除该参数,非 None 表示替换为该值
# 参考 Cloudreve pkg/wopi/wopi.go queryPlaceholders
_WOPI_QUERY_PLACEHOLDERS: dict[str, str | None] = {
'BUSINESS_USER': None,
'DC_LLCC': 'lng',
'DISABLE_ASYNC': None,
'DISABLE_CHAT': None,
'EMBEDDED': 'true',
'FULLSCREEN': 'true',
'HOST_SESSION_ID': None,
'SESSION_CONTEXT': None,
'RECORDING': None,
'THEME_ID': 'darkmode',
'UI_LLCC': 'lng',
'VALIDATOR_TEST_CATEGORY': None,
}
_WOPI_SRC_PLACEHOLDER = 'WOPI_SOURCE'
def process_wopi_action_url(raw_urlsrc: str) -> str:
"""
将 WOPI Discovery 中的原始 urlsrc 转换为 DiskNext 可用的 URL 模板。
处理流程(参考 Cloudreve generateActionUrl
1. 去除 ``<>`` 占位符标记
2. 解析查询参数,替换/删除已知占位符
3. ``WOPI_SOURCE`` → ``{wopi_src}``
注意access_token 和 access_token_ttl 不放在 URL 中,
根据 WOPI 规范它们通过 POST 表单字段传递给编辑器。
:param raw_urlsrc: WOPI Discovery XML 中的 urlsrc 原始值
:return: 处理后的 URL 模板字符串,包含 {wopi_src} 占位符
"""
# 去除 <> 标记
cleaned = raw_urlsrc.replace('<', '').replace('>', '')
parsed = urlparse(cleaned)
raw_params = parse_qs(parsed.query, keep_blank_values=True)
new_params: list[tuple[str, str]] = []
is_src_replaced = False
for key, values in raw_params.items():
value = values[0] if values else ''
# WOPI_SOURCE 占位符 → {wopi_src}
if value == _WOPI_SRC_PLACEHOLDER:
new_params.append((key, '{wopi_src}'))
is_src_replaced = True
continue
# 已知占位符
if value in _WOPI_QUERY_PLACEHOLDERS:
replacement = _WOPI_QUERY_PLACEHOLDERS[value]
if replacement is not None:
new_params.append((key, replacement))
# replacement 为 None 时删除该参数
continue
# 其他参数保留原值
new_params.append((key, value))
# 如果没有找到 WOPI_SOURCE 占位符,手动添加 WOPISrc
if not is_src_replaced:
new_params.append(('WOPISrc', '{wopi_src}'))
# LibreOffice/Collabora 需要 lang 参数(避免重复添加)
existing_keys = {k for k, _ in new_params}
if 'lang' not in existing_keys:
new_params.append(('lang', 'lng'))
# 注意access_token 和 access_token_ttl 不放在 URL 中
# 根据 WOPI 规范,它们通过 POST 表单字段传递给编辑器
# 重建 URL
new_query = urlencode(new_params, safe='{}')
result = urlunparse((
parsed.scheme,
parsed.netloc,
parsed.path,
parsed.params,
new_query,
'',
))
return result
def parse_wopi_discovery_xml(xml_content: str) -> tuple[dict[str, str], list[str]]:
"""
解析 WOPI Discovery XML提取扩展名到 URL 模板的映射。
XML 结构::
<wopi-discovery>
<net-zone name="external-https">
<app name="Writer" favIconUrl="...">
<action name="edit" ext="docx" urlsrc="https://..."/>
<action name="view" ext="docx" urlsrc="https://..."/>
</app>
</net-zone>
</wopi-discovery>
动作优先级edit > embedview > view参考 Cloudreve discovery.go
:param xml_content: WOPI Discovery 端点返回的 XML 字符串
:return: (action_urls, app_names) 元组
action_urls: {extension: processed_url_template}
app_names: 发现的应用名称列表
:raises ValueError: XML 解析失败或格式无效
"""
try:
root = ET.fromstring(xml_content)
except ET.ParseError as e:
raise ValueError(f"WOPI Discovery XML 解析失败: {e}")
# 查找 net-zone可能有多个取第一个非空的
net_zones = root.findall('net-zone')
if not net_zones:
raise ValueError("WOPI Discovery XML 缺少 net-zone 节点")
# ext_actions: {extension: {action_name: urlsrc}}
ext_actions: dict[str, dict[str, str]] = {}
app_names: list[str] = []
for net_zone in net_zones:
for app_elem in net_zone.findall('app'):
app_name = app_elem.get('name', '')
if app_name:
app_names.append(app_name)
for action_elem in app_elem.findall('action'):
action_name = action_elem.get('name', '')
ext = action_elem.get('ext', '')
urlsrc = action_elem.get('urlsrc', '')
if not ext or not urlsrc:
continue
# 只关注 edit / embedview / view 三种动作
if action_name not in ('edit', 'embedview', 'view'):
continue
if ext not in ext_actions:
ext_actions[ext] = {}
ext_actions[ext][action_name] = urlsrc
# 为每个扩展名选择最佳 URL: edit > embedview > view
action_urls: dict[str, str] = {}
for ext, actions_map in ext_actions.items():
selected_urlsrc: str | None = None
for preferred in ('edit', 'embedview', 'view'):
if preferred in actions_map:
selected_urlsrc = actions_map[preferred]
break
if selected_urlsrc:
action_urls[ext] = process_wopi_action_url(selected_urlsrc)
# 去重 app_names
seen: set[str] = set()
unique_names: list[str] = []
for name in app_names:
if name not in seen:
seen.add(name)
unique_names.append(name)
l.info(f"WOPI Discovery 解析完成: {len(action_urls)} 个扩展名, 应用: {unique_names}")
return action_urls, unique_names