mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-06 16:17:06 +08:00
feat: add WebDAV mapping configuration and UI in System Settings
This commit is contained in:
+44
-8
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user