feat: add WebDAV mapping configuration and UI in System Settings

This commit is contained in:
shiyu
2025-11-07 22:32:50 +08:00
parent 8957174e6f
commit e55a09d84f
5 changed files with 184 additions and 11 deletions
+44 -8
View File
@@ -20,6 +20,16 @@ from services.virtual_fs import (
copy_path, copy_path,
stream_file, 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"]) router = APIRouter(prefix="/webdav", tags=["webdav"])
@@ -140,12 +150,17 @@ def _normalize_fs_path(path: str) -> str:
@router.options("/{path:path}") @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()) return Response(status_code=200, headers=_dav_headers())
@router.api_route("/{path:path}", methods=["PROPFIND"]) @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) full_path = _normalize_fs_path(path)
depth = request.headers.get("Depth", "1").lower() depth = request.headers.get("Depth", "1").lower()
if depth not in ("0", "1", "infinity"): 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}") @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) full_path = _normalize_fs_path(path)
range_header = request.headers.get("Range") range_header = request.headers.get("Range")
return await stream_file(full_path, range_header) return await stream_file(full_path, range_header)
@router.head("/{path:path}") @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) full_path = _normalize_fs_path(path)
try: try:
st = await stat_file(full_path) 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"]) @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) full_path = _normalize_fs_path(path)
async def body_iter(): async def body_iter():
async for chunk in request.stream(): 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"]) @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) full_path = _normalize_fs_path(path)
await delete_path(full_path) await delete_path(full_path)
return Response(status_code=204, headers=_dav_headers()) return Response(status_code=204, headers=_dav_headers())
@router.api_route("/{path:path}", methods=["MKCOL"]) @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) full_path = _normalize_fs_path(path)
await make_dir(full_path) await make_dir(full_path)
return Response(status_code=201, headers=_dav_headers()) 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" overwrite = request.headers.get("Overwrite", "T").upper() != "F"
await copy_path(full_src, dst, overwrite=overwrite) await copy_path(full_src, dst, overwrite=overwrite)
return Response(status_code=201 if not overwrite else 204, headers=_dav_headers()) return Response(status_code=201 if not overwrite else 204, headers=_dav_headers())
+9
View File
@@ -286,6 +286,7 @@ export const en = {
'App Settings': 'App Settings', 'App Settings': 'App Settings',
'Email Settings': 'Email Settings', 'Email Settings': 'Email Settings',
'AI Settings': 'AI Settings', 'AI Settings': 'AI Settings',
'Protocol Mappings': 'Protocol Mappings',
'Vision Model': 'Vision Model', 'Vision Model': 'Vision Model',
'Embedding Model': 'Embedding Model', 'Embedding Model': 'Embedding Model',
'Embedding Dimension': 'Embedding Dimension', 'Embedding Dimension': 'Embedding Dimension',
@@ -332,6 +333,14 @@ export const en = {
'Favicon URL': 'Favicon URL', 'Favicon URL': 'Favicon URL',
'App Domain': 'App Domain', 'App Domain': 'App Domain',
'File Domain': 'File 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 Settings': 'SMTP Settings',
'SMTP Host': 'SMTP Host', 'SMTP Host': 'SMTP Host',
'Please input SMTP host': 'Please input SMTP host', 'Please input SMTP host': 'Please input SMTP host',
+9
View File
@@ -307,6 +307,7 @@ export const zh = {
'App Settings': '应用设置', 'App Settings': '应用设置',
'Email Settings': '邮箱设置', 'Email Settings': '邮箱设置',
'AI Settings': 'AI设置', 'AI Settings': 'AI设置',
'Protocol Mappings': '映射协议',
'Choose Template': '选择模板', 'Choose Template': '选择模板',
'Configure Provider': '配置提供商', 'Configure Provider': '配置提供商',
'Back to Templates': '返回选择', 'Back to Templates': '返回选择',
@@ -357,6 +358,14 @@ export const zh = {
'Favicon URL': 'Favicon 地址', 'Favicon URL': 'Favicon 地址',
'App Domain': '应用域名', 'App Domain': '应用域名',
'File 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 Settings': 'SMTP 配置',
'SMTP Host': 'SMTP 服务器', 'SMTP Host': 'SMTP 服务器',
'Please input SMTP host': '请输入 SMTP 服务器', 'Please input SMTP host': '请输入 SMTP 服务器',
@@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import PageCard from '../../components/PageCard'; import PageCard from '../../components/PageCard';
import { getAllConfig, setConfig } from '../../api/config'; 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 { useTheme } from '../../contexts/ThemeContext';
import '../../styles/settings-tabs.css'; import '../../styles/settings-tabs.css';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
@@ -11,10 +11,11 @@ import AppSettingsTab from './components/AppSettingsTab';
import AiSettingsTab from './components/AiSettingsTab'; import AiSettingsTab from './components/AiSettingsTab';
import VectorDbSettingsTab from './components/VectorDbSettingsTab'; import VectorDbSettingsTab from './components/VectorDbSettingsTab';
import EmailSettingsTab from './components/EmailSettingsTab'; 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 DEFAULT_TAB: TabKey = 'appearance';
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key); 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
<VectorDbSettingsTab isActive={activeTab === 'vector-db'} /> <VectorDbSettingsTab isActive={activeTab === 'vector-db'} />
), ),
}, },
{
key: 'mappings',
label: (
<span>
<CloudSyncOutlined style={{ marginRight: 8 }} />
{t('Protocol Mappings')}
</span>
),
children: (
<ProtocolMappingsTab
config={config}
loading={loading}
onSave={handleSave}
/>
),
},
]} ]}
/> />
</Space> </Space>
@@ -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<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
}
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 (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card
title={t('WebDAV Mapping')}
extra={(
<Space size={12} align="center">
<Switch
checked={webdavEnabled}
loading={webdavSaving}
disabled={loading}
onChange={handleToggleWebdav}
/>
</Space>
)}
>
<Descriptions
column={1}
size="small"
items={[
{
key: 'endpoint',
label: t('WebDAV Endpoint'),
children: (
<Typography.Text copyable={{ text: webdavEndpoint }}>
<code>{webdavEndpoint}</code>
</Typography.Text>
),
},
{
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.'),
},
]}
/>
<Typography.Text type="secondary">
{t('Toggle the switch to expose the virtual file system via WebDAV.')}
</Typography.Text>
</Card>
<Card title={t('S3 Mapping')}>
<Typography.Text type="secondary">{t('Coming soon')}</Typography.Text>
</Card>
</Space>
);
}