From e55a09d84f8648d8a90f7fc6b1373d2fc152f72c Mon Sep 17 00:00:00 2001 From: shiyu Date: Fri, 7 Nov 2025 22:32:50 +0800 Subject: [PATCH] feat: add WebDAV mapping configuration and UI in System Settings --- api/routes/webdav.py | 52 +++++++-- web/src/i18n/locales/en.ts | 9 ++ web/src/i18n/locales/zh.ts | 9 ++ .../SystemSettingsPage/SystemSettingsPage.tsx | 23 +++- .../components/ProtocolMappingsTab.tsx | 102 ++++++++++++++++++ 5 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx diff --git a/api/routes/webdav.py b/api/routes/webdav.py index 78a60ba..90c9b06 100644 --- a/api/routes/webdav.py +++ b/api/routes/webdav.py @@ -20,6 +20,16 @@ from services.virtual_fs import ( copy_path, stream_file, ) +from services.config import ConfigCenter + + +_WEBDAV_ENABLED_KEY = "WEBDAV_MAPPING_ENABLED" + + +async def _ensure_webdav_enabled() -> None: + enabled = await ConfigCenter.get(_WEBDAV_ENABLED_KEY, "1") + if str(enabled).strip().lower() in ("0", "false", "off", "no"): + raise HTTPException(503, detail="WebDAV mapping disabled") router = APIRouter(prefix="/webdav", tags=["webdav"]) @@ -140,12 +150,17 @@ def _normalize_fs_path(path: str) -> str: @router.options("/{path:path}") -async def options_root(path: str = ""): +async def options_root(path: str = "", _enabled: None = Depends(_ensure_webdav_enabled)): return Response(status_code=200, headers=_dav_headers()) @router.api_route("/{path:path}", methods=["PROPFIND"]) -async def propfind(request: Request, path: str, user: User = Depends(_get_basic_user)): +async def propfind( + request: Request, + path: str, + _enabled: None = Depends(_ensure_webdav_enabled), + user: User = Depends(_get_basic_user), +): full_path = _normalize_fs_path(path) depth = request.headers.get("Depth", "1").lower() if depth not in ("0", "1", "infinity"): @@ -187,14 +202,23 @@ async def propfind(request: Request, path: str, user: User = Depends(_get_basic_ @router.get("/{path:path}") -async def dav_get(path: str, request: Request, user: User = Depends(_get_basic_user)): +async def dav_get( + path: str, + request: Request, + _enabled: None = Depends(_ensure_webdav_enabled), + user: User = Depends(_get_basic_user), +): full_path = _normalize_fs_path(path) range_header = request.headers.get("Range") return await stream_file(full_path, range_header) @router.head("/{path:path}") -async def dav_head(path: str, user: User = Depends(_get_basic_user)): +async def dav_head( + path: str, + _enabled: None = Depends(_ensure_webdav_enabled), + user: User = Depends(_get_basic_user), +): full_path = _normalize_fs_path(path) try: st = await stat_file(full_path) @@ -216,7 +240,12 @@ async def dav_head(path: str, user: User = Depends(_get_basic_user)): @router.api_route("/{path:path}", methods=["PUT"]) -async def dav_put(path: str, request: Request, user: User = Depends(_get_basic_user)): +async def dav_put( + path: str, + request: Request, + _enabled: None = Depends(_ensure_webdav_enabled), + user: User = Depends(_get_basic_user), +): full_path = _normalize_fs_path(path) async def body_iter(): async for chunk in request.stream(): @@ -227,14 +256,22 @@ async def dav_put(path: str, request: Request, user: User = Depends(_get_basic_u @router.api_route("/{path:path}", methods=["DELETE"]) -async def dav_delete(path: str, user: User = Depends(_get_basic_user)): +async def dav_delete( + path: str, + _enabled: None = Depends(_ensure_webdav_enabled), + user: User = Depends(_get_basic_user), +): full_path = _normalize_fs_path(path) await delete_path(full_path) return Response(status_code=204, headers=_dav_headers()) @router.api_route("/{path:path}", methods=["MKCOL"]) -async def dav_mkcol(path: str, user: User = Depends(_get_basic_user)): +async def dav_mkcol( + path: str, + _enabled: None = Depends(_ensure_webdav_enabled), + user: User = Depends(_get_basic_user), +): full_path = _normalize_fs_path(path) await make_dir(full_path) return Response(status_code=201, headers=_dav_headers()) @@ -270,4 +307,3 @@ async def dav_copy(path: str, request: Request, user: User = Depends(_get_basic_ overwrite = request.headers.get("Overwrite", "T").upper() != "F" await copy_path(full_src, dst, overwrite=overwrite) return Response(status_code=201 if not overwrite else 204, headers=_dav_headers()) - diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index e470658..a07acd2 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -286,6 +286,7 @@ export const en = { 'App Settings': 'App Settings', 'Email Settings': 'Email Settings', 'AI Settings': 'AI Settings', + 'Protocol Mappings': 'Protocol Mappings', 'Vision Model': 'Vision Model', 'Embedding Model': 'Embedding Model', 'Embedding Dimension': 'Embedding Dimension', @@ -332,6 +333,14 @@ export const en = { 'Favicon URL': 'Favicon URL', 'App Domain': 'App Domain', 'File Domain': 'File Domain', + 'WebDAV Mapping': 'WebDAV Mapping', + 'WebDAV Endpoint': 'WebDAV Endpoint', + 'Basic (system account password)': 'Basic (system account password)', + 'Root Path': 'Root Path', + 'Client Compatibility': 'Client Compatibility', + 'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': 'Supports Finder, Windows network drive, rclone, and other WebDAV clients.', + 'Toggle the switch to expose the virtual file system via WebDAV.': 'Toggle the switch to expose the virtual file system via WebDAV.', + 'S3 Mapping': 'S3 Mapping', 'SMTP Settings': 'SMTP Settings', 'SMTP Host': 'SMTP Host', 'Please input SMTP host': 'Please input SMTP host', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index d6bf9fc..57fca12 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -307,6 +307,7 @@ export const zh = { 'App Settings': '应用设置', 'Email Settings': '邮箱设置', 'AI Settings': 'AI设置', + 'Protocol Mappings': '映射协议', 'Choose Template': '选择模板', 'Configure Provider': '配置提供商', 'Back to Templates': '返回选择', @@ -357,6 +358,14 @@ export const zh = { 'Favicon URL': 'Favicon 地址', 'App Domain': '应用域名', 'File Domain': '文件域名', + 'WebDAV Mapping': 'WebDAV 映射', + 'WebDAV Endpoint': 'WebDAV 访问地址', + 'Basic (system account password)': 'Basic(系统账号密码)', + 'Root Path': '根路径', + 'Client Compatibility': '客户端兼容性', + 'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': '兼容 Finder、Windows 网络驱动器、rclone 等 WebDAV 客户端。', + 'Toggle the switch to expose the virtual file system via WebDAV.': '通过开关控制是否对外暴露虚拟文件系统的 WebDAV 协议。', + 'S3 Mapping': 'S3 映射', 'SMTP Settings': 'SMTP 配置', 'SMTP Host': 'SMTP 服务器', 'Please input SMTP host': '请输入 SMTP 服务器', diff --git a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx index f4edc5a..be31dc9 100644 --- a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx +++ b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx @@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd'; import { useEffect, useState } from 'react'; import PageCard from '../../components/PageCard'; import { getAllConfig, setConfig } from '../../api/config'; -import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined } from '@ant-design/icons'; +import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined, CloudSyncOutlined } from '@ant-design/icons'; import { useTheme } from '../../contexts/ThemeContext'; import '../../styles/settings-tabs.css'; import { useI18n } from '../../i18n'; @@ -11,10 +11,11 @@ import AppSettingsTab from './components/AppSettingsTab'; import AiSettingsTab from './components/AiSettingsTab'; import VectorDbSettingsTab from './components/VectorDbSettingsTab'; import EmailSettingsTab from './components/EmailSettingsTab'; +import ProtocolMappingsTab from './components/ProtocolMappingsTab'; -type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db'; +type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db' | 'mappings'; -const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db']; +const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db', 'mappings']; const DEFAULT_TAB: TabKey = 'appearance'; const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key); @@ -191,6 +192,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett ), }, + { + key: 'mappings', + label: ( + + + {t('Protocol Mappings')} + + ), + children: ( + + ), + }, ]} /> diff --git a/web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx b/web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx new file mode 100644 index 0000000..b1c609f --- /dev/null +++ b/web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx @@ -0,0 +1,102 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Card, Descriptions, Space, Switch, Typography } from 'antd'; +import { useI18n } from '../../../i18n'; + +interface ProtocolMappingsTabProps { + config: Record; + loading: boolean; + onSave: (values: Record) => Promise; +} + +const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED'; + +const truthy = new Set(['1', 'true', 'yes', 'on']); + +export default function ProtocolMappingsTab({ config, loading, onSave }: ProtocolMappingsTabProps) { + const { t } = useI18n(); + const [webdavEnabled, setWebdavEnabled] = useState(() => truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase())); + const [webdavSaving, setWebdavSaving] = useState(false); + + useEffect(() => { + setWebdavEnabled(truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase())); + }, [config]); + + const webdavEndpoint = useMemo(() => { + const configured = (config.APP_DOMAIN ?? '').trim(); + if (configured) { + const hasProtocol = configured.startsWith('http://') || configured.startsWith('https://'); + const base = hasProtocol ? configured : `https://${configured}`; + return base.replace(/\/$/, '') + '/webdav'; + } + if (typeof window !== 'undefined') { + return window.location.origin.replace(/\/$/, '') + '/webdav'; + } + return '/webdav'; + }, [config.APP_DOMAIN]); + + const handleToggleWebdav = async (checked: boolean) => { + setWebdavSaving(true); + try { + await onSave({ [WEBDAV_KEY]: checked ? '1' : '0' }); + setWebdavEnabled(checked); + } finally { + setWebdavSaving(false); + } + }; + + return ( + + + + + )} + > + + {webdavEndpoint} + + ), + }, + { + key: 'auth', + label: t('Authentication'), + children: t('Basic (system account password)'), + }, + { + key: 'root', + label: t('Root Path'), + children: '/webdav', + }, + { + key: 'compat', + label: t('Client Compatibility'), + children: t('Supports Finder, Windows network drive, rclone, and other WebDAV clients.'), + }, + ]} + /> + + {t('Toggle the switch to expose the virtual file system via WebDAV.')} + + + + + {t('Coming soon')} + + + ); +}