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

View File

@@ -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())

View File

@@ -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',

View File

@@ -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 服务器',

View File

@@ -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
<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>

View File

@@ -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>
);
}