feat: Add the ability to obtain direct file links

This commit is contained in:
shiyu
2025-08-27 12:23:26 +08:00
parent 202c2ed5af
commit d5d597a582
10 changed files with 322 additions and 64 deletions

View File

@@ -145,11 +145,12 @@ async def stream_endpoint(
@router.get("/temp-link/{full_path:path}")
async def get_temp_link(
full_path: str,
current_user: Annotated[User, Depends(get_current_active_user)]
current_user: Annotated[User, Depends(get_current_active_user)],
expires_in: int = Query(3600, description="有效时间(秒), 0或负数表示永久")
):
"""获取文件的临时公开访问令牌"""
full_path = '/' + full_path if not full_path.startswith('/') else full_path
token = await generate_temp_link_token(full_path)
token = await generate_temp_link_token(full_path, expires_in=expires_in)
return success({"token": token, "path": full_path})

View File

@@ -481,8 +481,12 @@ async def get_temp_link_secret_key() -> bytes:
async def generate_temp_link_token(path: str, expires_in: int = 3600) -> str:
"""为文件路径生成一个有时效的令牌"""
expiration_time = int(time.time() + expires_in)
"""为文件路径生成一个有时效的令牌。expires_in <= 0 表示永久"""
if expires_in <= 0:
expiration_time = "0"
else:
expiration_time = str(int(time.time() + expires_in))
message = f"{path}:{expiration_time}".encode('utf-8')
secret_key = await get_temp_link_secret_key()
signature = hmac.new(secret_key, message, hashlib.sha256).digest()
@@ -496,15 +500,16 @@ async def verify_temp_link_token(token: str) -> str:
try:
decoded_token = base64.urlsafe_b64decode(token).decode('utf-8')
path, expiration_time_str, signature_b64 = decoded_token.rsplit(':', 2)
expiration_time = int(expiration_time_str)
signature = base64.urlsafe_b64decode(signature_b64)
except (ValueError, TypeError, base64.binascii.Error):
raise HTTPException(status_code=400, detail="Invalid token format")
if time.time() > expiration_time:
raise HTTPException(status_code=410, detail="Link has expired")
if expiration_time_str != "0":
expiration_time = int(expiration_time_str)
if time.time() > expiration_time:
raise HTTPException(status_code=410, detail="Link has expired")
message = f"{path}:{expiration_time}".encode('utf-8')
message = f"{path}:{expiration_time_str}".encode('utf-8')
secret_key = await get_temp_link_secret_key()
expected_signature = hmac.new(secret_key, message, hashlib.sha256).digest()

View File

@@ -50,7 +50,8 @@ export const vfsApi = {
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
getTempLinkToken: (path: string) => request<{token: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}`),
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
request<{token: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
const enc = encodeURI(fullPath.replace(/^\/+/, ''));

View File

@@ -9,6 +9,7 @@ import { useAppWindows } from './hooks/useAppWindows.tsx';
import { useContextMenu } from './hooks/useContextMenu';
import { useProcessor } from './hooks/useProcessor';
import { useThumbnails } from './hooks/useThumbnails';
import { useUploader } from './hooks/useUploader';
import { Header } from './components/Header';
import { GridView } from './components/GridView';
import { FileListView } from './components/FileListView';
@@ -17,7 +18,9 @@ import { ContextMenu } from './components/ContextMenu';
import { CreateDirModal } from './components/Modals/CreateDirModal';
import { RenameModal } from './components/Modals/RenameModal';
import { ProcessorModal } from './components/Modals/ProcessorModal';
import UploadModal from './components/Modals/UploadModal';
import { ShareModal } from './components/Modals/ShareModal';
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
import { FileDetailModal } from './components/FileDetailModal';
import type { ViewMode } from './types';
import { vfsApi, type VfsEntry } from '../../api/client';
@@ -30,9 +33,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
// --- Hooks ---
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
const { uploading, fileInputRef, doCreateDir, doDelete, doRename, doDownload, doShare, handleUploadClick, handleFilesSelected } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries) });
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh);
const processorHook = useProcessor({ path, processorTypes, refresh });
const { thumbs } = useThumbnails(entries, path);
@@ -41,6 +45,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
const [detailData, setDetailData] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false);
@@ -91,17 +96,16 @@ const FileExplorerPage = memo(function FileExplorerPage() {
navKey={navKey}
path={path}
loading={loading}
uploading={uploading}
viewMode={viewMode}
onGoUp={goUp}
onNavigate={navigateTo}
onRefresh={refresh}
onCreateDir={() => setCreatingDir(true)}
onUpload={handleUploadClick}
onUpload={uploader.openModal}
onSetViewMode={setViewMode}
/>
<input ref={fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={handleFilesSelected} />
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
{loading && entries.length === 0 ? (
@@ -155,6 +159,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onCancel={() => setSharingEntries([])}
/>
)}
<DirectLinkModal
entry={directLinkEntry}
path={path}
open={!!directLinkEntry}
onCancel={() => setDirectLinkEntry(null)}
/>
<ProcessorModal
entry={processorHook.processorModal.entry}
visible={processorHook.processorModal.visible}
@@ -191,12 +201,18 @@ const FileExplorerPage = memo(function FileExplorerPage() {
processorHook.setSelectedProcessor(type);
processorHook.openProcessorModal(entry);
}}
onUpload={handleUploadClick}
onUpload={uploader.openModal}
onCreateDir={() => setCreatingDir(true)}
onShare={doShare}
onGetDirectLink={doGetDirectLink}
/>
)}
<UploadModal
visible={uploader.isModalVisible}
files={uploader.files}
onClose={uploader.closeModal}
onStartUpload={uploader.startUpload}
/>
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
</div>
);

View File

@@ -4,7 +4,7 @@ import type { VfsEntry } from '../../../api/client';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
import {
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined
} from '@ant-design/icons';
interface ContextMenuProps {
@@ -25,6 +25,7 @@ interface ContextMenuProps {
onUpload: () => void;
onCreateDir: () => void;
onShare: (entries: VfsEntry[]) => void;
onGetDirectLink: (entry: VfsEntry) => void;
}
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
@@ -86,6 +87,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
icon: <ShareAltOutlined />,
onClick: () => actions.onShare(targetEntries),
},
{
key: 'directLink',
label: '获取直链',
icon: <LinkOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir,
onClick: () => actions.onGetDirectLink(targetEntries[0]),
},
{
key: 'download',
label: '下载',

View File

@@ -7,7 +7,6 @@ interface HeaderProps {
navKey: string;
path: string;
loading: boolean;
uploading: boolean;
viewMode: ViewMode;
onGoUp: () => void;
onNavigate: (path: string) => void;
@@ -20,7 +19,6 @@ interface HeaderProps {
export const Header: React.FC<HeaderProps> = ({
path,
loading,
uploading,
viewMode,
onGoUp,
onNavigate,
@@ -101,7 +99,7 @@ export const Header: React.FC<HeaderProps> = ({
<Space size={8} wrap>
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}></Button>
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}></Button>
<Button size="small" icon={<UploadOutlined />} loading={uploading} onClick={onUpload}></Button>
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}></Button>
<Segmented
size="small"
value={viewMode}

View File

@@ -0,0 +1,79 @@
import { memo, useState, useEffect } from 'react';
import { Modal, Radio, message, Button, Typography, Input } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../../api/client';
import { vfsApi } from '../../../../api/client';
interface DirectLinkModalProps {
entry: VfsEntry | null;
path: string;
open: boolean;
onCancel: () => void;
}
export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open, onCancel }: DirectLinkModalProps) {
const [loading, setLoading] = useState(false);
const [expiresIn, setExpiresIn] = useState(3600);
const [link, setLink] = useState('');
useEffect(() => {
if (open && entry) {
setLink('');
generateLink();
}
}, [open, entry, expiresIn]);
const generateLink = async () => {
if (!entry) return;
setLoading(true);
try {
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
const res = await vfsApi.getTempLinkToken(fullPath, expiresIn);
const tempLink = `${window.location.origin}/api/fs/public/${res.token}`;
setLink(tempLink);
} catch (e: any) {
message.error(e.message || '生成链接失败');
} finally {
setLoading(false);
}
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
message.success('已复制到剪贴板');
};
const handleExpiresChange = (e: any) => {
setExpiresIn(e.target.value);
};
return (
<Modal
title="获取直链"
open={open}
onCancel={onCancel}
footer={[
<Button key="back" onClick={onCancel}>
</Button>,
]}
>
<Typography.Paragraph>
<strong>{entry?.name}</strong> 访
</Typography.Paragraph>
<Radio.Group value={expiresIn} onChange={handleExpiresChange} style={{ marginBottom: 16 }}>
<Radio.Button value={3600}>1 </Radio.Button>
<Radio.Button value={86400}>1 </Radio.Button>
<Radio.Button value={604800}>7 </Radio.Button>
<Radio.Button value={0}></Radio.Button>
</Radio.Group>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={link} disabled={loading} placeholder={loading ? "正在生成链接..." : "链接将显示在这里"} />
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
</Button>
</div>
</Modal>
);
});

View File

@@ -0,0 +1,92 @@
import React, { useEffect } from 'react';
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import type { UploadFile } from '../../hooks/useUploader';
interface UploadModalProps {
visible: boolean;
files: UploadFile[];
onClose: () => void;
onStartUpload: () => void;
}
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
const allSuccess = files.every(f => f.status === 'success');
useEffect(() => {
if (visible && files.length > 0 && files.every(f => f.status === 'pending')) {
onStartUpload();
}
}, [visible, files, onStartUpload]);
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
message.success('链接已复制到剪贴板');
};
const renderStatus = (file: UploadFile) => {
switch (file.status) {
case 'uploading':
return <Progress percent={Math.round(file.progress)} size="small" />;
case 'success':
return (
<Flex align="center" gap={8}>
<CheckCircleFilled style={{ color: '#52c41a' }} />
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}></Typography.Text>
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
</Flex>
);
case 'error':
return (
<Flex align="center" gap={8}>
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
<Typography.Text type="danger" title={file.error}></Typography.Text>
</Flex>
);
default:
return <Typography.Text type="secondary"></Typography.Text>;
}
};
return (
<Modal
open={visible}
title="上传文件"
width={600}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
{allSuccess ? '关闭' : '完成'}
</Button>,
]}
>
<List
dataSource={files}
itemLayout="horizontal"
renderItem={file => (
<List.Item
style={{
padding: '12px 8px',
borderRadius: 8,
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
<Typography.Text ellipsis={{ tooltip: file.file.name }} style={{ maxWidth: '60%' }}>
{file.file.name}
</Typography.Text>
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
{renderStatus(file)}
</div>
</Flex>
</List.Item>
)}
/>
</Modal>
);
};
export default UploadModal;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react';
import { useCallback } from 'react';
import { message, Modal } from 'antd';
import { vfsApi, type VfsEntry } from '../../../api/client';
@@ -7,12 +7,10 @@ interface FileActionsParams {
refresh: () => void;
clearSelection: () => void;
onShare: (entries: VfsEntry[]) => void;
onGetDirectLink: (entry: VfsEntry) => void;
}
export function useFileActions({ path, refresh, clearSelection, onShare }: FileActionsParams) {
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
const doCreateDir = useCallback(async (name: string) => {
if (!name.trim()) {
message.warning('请输入名称');
@@ -78,42 +76,6 @@ export function useFileActions({ path, refresh, clearSelection, onShare }: FileA
}
}, [path]);
const handleUploadClick = useCallback(() => {
if (uploading) return;
fileInputRef.current?.click();
}, [uploading]);
const handleFilesSelected = useCallback(async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = ev.target.files;
if (!files || files.length === 0) return;
const dir = path === '/' ? '' : path;
setUploading(true);
const uploadedNames: string[] = [];
try {
for (const file of Array.from(files)) {
const dest = (dir + '/' + file.name).replace(/\/+/g, '/');
const key = 'upload-' + file.name;
await vfsApi.uploadStream(dest, file, true, (loaded, total) => {
const pct = total ? (loaded / total * 100) : 0;
message.open({
key,
type: 'loading',
content: `上传 ${file.name} ${pct.toFixed(1)}%`
});
});
message.open({ key, type: 'success', content: `上传完成: ${file.name}`, duration: 2 });
uploadedNames.push(file.name);
}
refresh();
// You might want to select the new files after upload, this can be handled in the main component
} catch (e: any) {
message.error(e.message || '上传失败');
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}, [path, refresh]);
const doShare = useCallback((entries: VfsEntry[]) => {
if (entries.length === 0) {
message.warning('请选择要分享的文件或目录');
@@ -122,15 +84,20 @@ export function useFileActions({ path, refresh, clearSelection, onShare }: FileA
onShare(entries);
}, [onShare]);
const doGetDirectLink = useCallback((entry: VfsEntry) => {
if (entry.is_dir) {
message.warning('不支持获取目录的直链');
return;
}
onGetDirectLink(entry);
}, [onGetDirectLink]);
return {
uploading,
fileInputRef,
doCreateDir,
doDelete,
doRename,
doDownload,
doShare,
handleUploadClick,
handleFilesSelected,
doGetDirectLink,
};
}

View File

@@ -0,0 +1,91 @@
import { useState, useCallback, useRef } from 'react';
import { vfsApi } from '../../../api/client';
import { message }
from 'antd';
export interface UploadFile {
id: string;
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
progress: number;
error?: string;
permanentLink?: string;
}
export function useUploader(path: string, onUploadComplete: () => void) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openModal = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
const closeModal = useCallback(() => {
setIsModalVisible(false);
setFiles([]);
}, []);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = event.target.files;
if (selectedFiles && selectedFiles.length > 0) {
const newFiles: UploadFile[] = Array.from(selectedFiles).map(file => ({
id: `${file.name}-${Date.now()}`,
file,
status: 'pending',
progress: 0,
}));
setFiles(newFiles);
setIsModalVisible(true);
// reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const startUpload = useCallback(async () => {
if (files.length === 0) {
return;
}
const dir = path === '/' ? '' : path;
for (const uploadFile of files) {
if (uploadFile.status !== 'pending') continue;
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'uploading' } : f));
const dest = (dir + '/' + uploadFile.file.name).replace(/\/+/g, '/');
try {
await vfsApi.uploadStream(dest, uploadFile.file, true, (loaded, total) => {
const progress = total > 0 ? (loaded / total) * 100 : 0;
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
});
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10); // 10 years
const permanentLink = vfsApi.getTempPublicUrl(link.token);
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
} catch (e: any) {
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
message.error(`上传失败: ${uploadFile.file.name} - ${e.message}`);
}
}
onUploadComplete();
}, [files, path, onUploadComplete]);
return {
files,
isModalVisible,
fileInputRef,
openModal,
closeModal,
handleFileChange,
startUpload,
};
}