Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2589ee4b3 | ||
|
|
050075d9c8 | ||
|
|
62e8acf757 | ||
|
|
ef6347a8b4 |
53
frontend/src/api/token.ts
Normal file
53
frontend/src/api/token.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export interface ApiToken {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTokenRes {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonHeaders: HeadersInit = {'Content-Type': 'application/json'};
|
||||||
|
|
||||||
|
export const tokenApi = {
|
||||||
|
list: async (): Promise<ApiToken[]> => {
|
||||||
|
const res = await fetch('/api/token', {headers: jsonHeaders});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '查询失败');
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (name: string): Promise<CreateTokenRes> => {
|
||||||
|
const res = await fetch('/api/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({name}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '创建失败');
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
const res = await fetch('/api/token', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({id}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(json.msg || '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface UploadSettings {
|
||||||
|
maxDownloads: number; // 0 = unlimited
|
||||||
|
expiresIn: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
interface UploadRes {
|
interface UploadRes {
|
||||||
code: string
|
code: string
|
||||||
@@ -10,18 +14,25 @@ export const useFileUpload = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const uploadFile = async (file: File): Promise<string> => {
|
const uploadFile = async (file: File, settings?: UploadSettings): Promise<string> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
|
const maxDownloads = settings?.maxDownloads ?? 3;
|
||||||
|
const expiresIn = settings?.expiresIn ?? 28800;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/ushare/${file.name}`;
|
const url = `/api/ushare/${file.name}`;
|
||||||
|
|
||||||
// 1. 初始化上传
|
// 1. 初始化上传
|
||||||
const res1 = await fetch(url, {
|
const res1 = await fetch(url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {"X-File-Size": file.size.toString()}
|
headers: {
|
||||||
|
"X-File-Size": file.size.toString(),
|
||||||
|
"X-Max-Downloads": maxDownloads.toString(),
|
||||||
|
"X-Expires-In": expiresIn.toString(),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res1.ok) {
|
if (!res1.ok) {
|
||||||
@@ -30,7 +41,6 @@ export const useFileUpload = () => {
|
|||||||
window.location.href = "/login?next=/share"
|
window.location.href = "/login?next=/share"
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("上传失败<1>");
|
throw new Error("上传失败<1>");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,19 +74,17 @@ export const useFileUpload = () => {
|
|||||||
throw new Error(`上传失败<3>: ${err}`);
|
throw new Error(`上传失败<3>: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新进度
|
const currentProgress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
|
||||||
// const currentProgress = Number(((chunkIndex + 1) / totalChunks * 100).toFixed(2)); // 小数
|
|
||||||
const currentProgress = Math.round(((chunkIndex + 1) / totalChunks) * 100); // 整数 0-100
|
|
||||||
setProgress(currentProgress);
|
setProgress(currentProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
return code;
|
return code;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // 将错误继续抛出以便组件处理
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { uploadFile, progress, loading, error };
|
return { uploadFile, progress, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {FileSharing} from "./page/share/share.tsx";
|
|||||||
import {LocalSharing} from "./page/local/local.tsx";
|
import {LocalSharing} from "./page/local/local.tsx";
|
||||||
import {TestPage} from "./page/test/test.tsx";
|
import {TestPage} from "./page/test/test.tsx";
|
||||||
import {AdminPage} from "./page/admin/admin.tsx";
|
import {AdminPage} from "./page/admin/admin.tsx";
|
||||||
|
import {SelfPage} from "./page/self/self.tsx";
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
const root = createRoot(container!)
|
const root = createRoot(container!)
|
||||||
@@ -14,6 +15,7 @@ const router = createBrowserRouter([
|
|||||||
{path: "/login", element: <Login />},
|
{path: "/login", element: <Login />},
|
||||||
{path: "/share", element: <FileSharing />},
|
{path: "/share", element: <FileSharing />},
|
||||||
{path: "/admin", element: <AdminPage />},
|
{path: "/admin", element: <AdminPage />},
|
||||||
|
{path: "/self", element: <SelfPage />},
|
||||||
{path: "/test", element: <TestPage />},
|
{path: "/test", element: <TestPage />},
|
||||||
{path: "*", element: <LocalSharing />},
|
{path: "*", element: <LocalSharing />},
|
||||||
])
|
])
|
||||||
|
|||||||
429
frontend/src/page/self/self.tsx
Normal file
429
frontend/src/page/self/self.tsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {createUseStyles} from 'react-jss';
|
||||||
|
import {tokenApi, ApiToken, CreateTokenRes} from '../../api/token.ts';
|
||||||
|
import {message} from '../../hook/message/u-message.tsx';
|
||||||
|
import {UButton} from '../../component/button/u-button.tsx';
|
||||||
|
|
||||||
|
const useStyle = createUseStyles({
|
||||||
|
container: {
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
padding: '24px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
fontFamily: "'Segoe UI', Arial, sans-serif",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: '2px solid #2c9678',
|
||||||
|
color: '#2c9678',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#2c9678',
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#C8E6C9',
|
||||||
|
boxShadow: 'inset 0 0 15px rgba(56, 142, 60, 0.15)',
|
||||||
|
borderRadius: '15px',
|
||||||
|
padding: '24px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
color: '#2c9678',
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
backgroundColor: 'rgba(44,150,120,0.15)',
|
||||||
|
padding: '10px 12px',
|
||||||
|
textAlign: 'left',
|
||||||
|
color: '#2c9678',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderBottom: '2px solid rgba(44,150,120,0.3)',
|
||||||
|
},
|
||||||
|
td: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderBottom: '1px solid rgba(44,150,120,0.2)',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
trHover: {
|
||||||
|
'&:hover': {backgroundColor: 'rgba(44,150,120,0.05)'},
|
||||||
|
},
|
||||||
|
emptyRow: {
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#888',
|
||||||
|
padding: '24px',
|
||||||
|
},
|
||||||
|
actionBtn: {
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
'&:hover': {opacity: 0.8},
|
||||||
|
},
|
||||||
|
deleteBtn: {
|
||||||
|
backgroundColor: '#e53935',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
topBar: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
// Dialog overlay
|
||||||
|
overlay: {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
backgroundColor: '#C8E6C9',
|
||||||
|
borderRadius: '15px',
|
||||||
|
padding: '28px',
|
||||||
|
width: '440px',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||||
|
},
|
||||||
|
dialogTitle: {
|
||||||
|
color: '#2c9678',
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: '20px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
display: 'block',
|
||||||
|
color: '#2c9678',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid rgba(44,150,120,0.4)',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
outline: 'none',
|
||||||
|
'&:focus': {borderColor: '#2c9678'},
|
||||||
|
},
|
||||||
|
dialogFooter: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
cancelBtn: {
|
||||||
|
padding: '8px 18px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '2px solid #2c9678',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#2c9678',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
||||||
|
},
|
||||||
|
tokenValueBox: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#1a1a2e',
|
||||||
|
border: '1px solid rgba(44,150,120,0.4)',
|
||||||
|
},
|
||||||
|
warningText: {
|
||||||
|
color: '#e53935',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
copyBtn: {
|
||||||
|
padding: '8px 18px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none',
|
||||||
|
background: '#2c9678',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
'&:hover': {backgroundColor: '#1f6d5a'},
|
||||||
|
},
|
||||||
|
usageCard: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.5)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '16px 20px',
|
||||||
|
},
|
||||||
|
usageTitle: {
|
||||||
|
color: '#2c9678',
|
||||||
|
margin: '0 0 10px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
pre: {
|
||||||
|
margin: '6px 0',
|
||||||
|
padding: '10px 14px',
|
||||||
|
backgroundColor: '#1a1a2e',
|
||||||
|
color: '#c3e88d',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
role_label: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelfPage: React.FC = () => {
|
||||||
|
const style = useStyle();
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newTokenName, setNewTokenName] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createdToken, setCreatedToken] = useState<CreateTokenRes | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/uauth/me')
|
||||||
|
.then(async res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
const s: Session = json.data;
|
||||||
|
setSession(s);
|
||||||
|
if (!s.permissions.includes('token_manage')) {
|
||||||
|
message.warning('无 Token 管理权限');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return loadTokens();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
window.location.href = '/login';
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadTokens() {
|
||||||
|
try {
|
||||||
|
const list = await tokenApi.list();
|
||||||
|
setTokens(list ?? []);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newTokenName.trim()) {
|
||||||
|
message.warning('请输入 Token 名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await tokenApi.create(newTokenName.trim());
|
||||||
|
setCreatedToken(res);
|
||||||
|
setNewTokenName('');
|
||||||
|
setShowCreate(false);
|
||||||
|
await loadTokens();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number, name: string) {
|
||||||
|
if (!confirm(`确认吊销 Token「${name}」?`)) return;
|
||||||
|
try {
|
||||||
|
await tokenApi.delete(id);
|
||||||
|
message.success('已吊销');
|
||||||
|
setTokens(prev => prev.filter(t => t.id !== id));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyToken(val: string) {
|
||||||
|
navigator.clipboard.writeText(val)
|
||||||
|
.then(() => message.success('已复制到剪贴板'))
|
||||||
|
.catch(() => message.warning('复制失败,请手动复制'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(s: string | null) {
|
||||||
|
if (!s) return '-';
|
||||||
|
return new Date(s).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTokenPerm = session?.permissions.includes('token_manage') ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.container}>
|
||||||
|
<div className={style.header}>
|
||||||
|
<button className={style.backBtn} onClick={() => window.history.back()}>← 返回</button>
|
||||||
|
<h2 className={style.title}>个人中心</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && session && (
|
||||||
|
<>
|
||||||
|
{/* User info card */}
|
||||||
|
<div className={style.card}>
|
||||||
|
<h3 className={style.cardTitle}>账号信息</h3>
|
||||||
|
<p style={{margin: '4px 0', color: '#333', fontSize: '14px'}}>
|
||||||
|
用户名:<strong>{session.username}</strong>
|
||||||
|
</p>
|
||||||
|
<p style={{margin: '4px 0', color: '#333', fontSize: '14px'}}>
|
||||||
|
角色:<strong>{session.role_label}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token management card */}
|
||||||
|
{hasTokenPerm && (
|
||||||
|
<div className={style.card}>
|
||||||
|
<div className={style.topBar}>
|
||||||
|
<h3 className={style.cardTitle} style={{margin: 0}}>API Token</h3>
|
||||||
|
<UButton onClick={() => setShowCreate(true)}>+ 新建 Token</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className={style.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={style.th}>名称</th>
|
||||||
|
<th className={style.th}>创建时间</th>
|
||||||
|
<th className={style.th}>最后使用</th>
|
||||||
|
<th className={style.th}>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tokens.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className={style.td} colSpan={4} style={{textAlign: 'center', color: '#888'}}>
|
||||||
|
暂无 Token,点击「新建 Token」创建
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tokens.map(t => (
|
||||||
|
<tr key={t.id} className={style.trHover}>
|
||||||
|
<td className={style.td}>{t.name}</td>
|
||||||
|
<td className={style.td}>{formatDate(t.created_at)}</td>
|
||||||
|
<td className={style.td}>{formatDate(t.last_used_at)}</td>
|
||||||
|
<td className={style.td}>
|
||||||
|
<button
|
||||||
|
className={`${style.actionBtn} ${style.deleteBtn}`}
|
||||||
|
onClick={() => handleDelete(t.id, t.name)}
|
||||||
|
>
|
||||||
|
吊销
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Usage guide */}
|
||||||
|
<div style={{marginTop: '20px'}}>
|
||||||
|
<div className={style.usageCard}>
|
||||||
|
<p className={style.usageTitle}>使用方式(curl 示例)</p>
|
||||||
|
<pre className={style.pre}>{`curl -H "Authorization: Bearer <your_token>" \\
|
||||||
|
-T <file_path> \\
|
||||||
|
https://<your_domain>/api/v1/upload/<filename>`}</pre>
|
||||||
|
<p style={{margin: '8px 0 4px', color: '#555', fontSize: '13px'}}>返回示例:</p>
|
||||||
|
<pre className={style.pre}>{`{"status":200,"data":{"code":"ABCD1234"}}`}</pre>
|
||||||
|
<p style={{margin: '8px 0 4px', color: '#555', fontSize: '13px'}}>下载文件:</p>
|
||||||
|
<pre className={style.pre}>{`https://<your_domain>/ushare/<code>`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasTokenPerm && (
|
||||||
|
<div className={style.card}>
|
||||||
|
<p style={{color: '#888', margin: 0}}>当前角色无 Token 管理权限</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create token dialog */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className={style.overlay} onClick={() => setShowCreate(false)}>
|
||||||
|
<div className={style.dialog} onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className={style.dialogTitle}>新建 API Token</h3>
|
||||||
|
<label className={style.label}>Token 名称</label>
|
||||||
|
<input
|
||||||
|
className={style.input}
|
||||||
|
placeholder="例:服务器上传脚本"
|
||||||
|
value={newTokenName}
|
||||||
|
onChange={e => setNewTokenName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleCreate()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className={style.dialogFooter}>
|
||||||
|
<button className={style.cancelBtn} onClick={() => setShowCreate(false)}>取消</button>
|
||||||
|
<UButton onClick={handleCreate} loading={creating} disabled={creating}>
|
||||||
|
创建
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Newly created token display - shown only once */}
|
||||||
|
{createdToken && (
|
||||||
|
<div className={style.overlay} onClick={() => setCreatedToken(null)}>
|
||||||
|
<div className={style.dialog} onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className={style.dialogTitle}>Token 已创建</h3>
|
||||||
|
<p className={style.warningText}>
|
||||||
|
请立即复制并妥善保存,Token 值仅显示一次,关闭后无法再次查看!
|
||||||
|
</p>
|
||||||
|
<label className={style.label}>Token 名称:{createdToken.name}</label>
|
||||||
|
<div className={style.tokenValueBox}>{createdToken.token}</div>
|
||||||
|
<div className={style.dialogFooter}>
|
||||||
|
<button className={style.cancelBtn} onClick={() => setCreatedToken(null)}>关闭</button>
|
||||||
|
<button className={style.copyBtn} onClick={() => handleCopyToken(createdToken.token)}>
|
||||||
|
复制 Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
81
frontend/src/page/share/component/nav-bar.tsx
Normal file
81
frontend/src/page/share/component/nav-bar.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {createUseStyles} from 'react-jss';
|
||||||
|
|
||||||
|
const useStyle = createUseStyles({
|
||||||
|
nav: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 24px',
|
||||||
|
height: '48px',
|
||||||
|
backgroundColor: '#2c9678',
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '18px',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
fontSize: '13px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
color: 'rgba(255,255,255,0.4)',
|
||||||
|
fontSize: '13px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NavBar: React.FC = () => {
|
||||||
|
const style = useStyle();
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [hasTokenPerm, setHasTokenPerm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/uauth/me').then(async res => {
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
const perms: string[] = json.data?.permissions ?? [];
|
||||||
|
setIsAdmin(perms.includes('user_manage'));
|
||||||
|
setHasTokenPerm(perms.includes('token_manage'));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showLinks = isAdmin || hasTokenPerm;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={style.nav}>
|
||||||
|
<a href="/share" className={style.brand}>UShare</a>
|
||||||
|
{showLinks && (
|
||||||
|
<div className={style.links}>
|
||||||
|
{hasTokenPerm && (
|
||||||
|
<a href="/self" className={style.link}>个人中心</a>
|
||||||
|
)}
|
||||||
|
{isAdmin && hasTokenPerm && (
|
||||||
|
<span className={style.divider}>|</span>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<a href="/admin" className={style.link}>管理控制台</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {createUseStyles} from "react-jss";
|
import {createUseStyles} from "react-jss";
|
||||||
import {UButton} from "../../../component/button/u-button.tsx";
|
import {UButton} from "../../../component/button/u-button.tsx";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useState} from "react";
|
||||||
import {useStore} from "../../../store/share.ts";
|
import {useStore} from "../../../store/share.ts";
|
||||||
import {message} from "../../../hook/message/u-message.tsx";
|
import {message} from "../../../hook/message/u-message.tsx";
|
||||||
import {useFileUpload} from "../../../api/upload.ts";
|
import {useFileUpload, UploadSettings} from "../../../api/upload.ts";
|
||||||
|
|
||||||
const useUploadStyle = createUseStyles({
|
const useUploadStyle = createUseStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -60,15 +60,65 @@ const useUploadStyle = createUseStyles({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': {}
|
'&:hover': {}
|
||||||
},
|
},
|
||||||
adminLink: {
|
// Advanced settings
|
||||||
display: 'block',
|
advToggle: {
|
||||||
textAlign: 'center',
|
|
||||||
marginTop: '16px',
|
marginTop: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
color: '#2c9678',
|
color: '#2c9678',
|
||||||
fontSize: '12px',
|
fontSize: '13px',
|
||||||
textDecoration: 'none',
|
userSelect: 'none',
|
||||||
opacity: 0.8,
|
opacity: 0.75,
|
||||||
'&:hover': {opacity: 1, textDecoration: 'underline'},
|
'&:hover': {opacity: 1},
|
||||||
|
},
|
||||||
|
advPanel: {
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '14px 16px',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.5)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
},
|
||||||
|
advRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px',
|
||||||
|
},
|
||||||
|
advLabel: {
|
||||||
|
color: '#2c9678',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
advInput: {
|
||||||
|
width: '80px',
|
||||||
|
padding: '5px 8px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
border: '1px solid rgba(44,150,120,0.4)',
|
||||||
|
fontSize: '13px',
|
||||||
|
textAlign: 'center',
|
||||||
|
outline: 'none',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
'&:focus': {borderColor: '#2c9678'},
|
||||||
|
},
|
||||||
|
advSelect: {
|
||||||
|
padding: '5px 8px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
border: '1px solid rgba(44,150,120,0.4)',
|
||||||
|
fontSize: '13px',
|
||||||
|
outline: 'none',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
color: '#333',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:focus': {borderColor: '#2c9678'},
|
||||||
|
},
|
||||||
|
advHint: {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#888',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,7 +175,7 @@ const useShowStyle = createUseStyles({
|
|||||||
height: "24px",
|
height: "24px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
|
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
codeWrapper: {
|
codeWrapper: {
|
||||||
@@ -173,36 +223,47 @@ const useShowStyle = createUseStyles({
|
|||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metaInfo: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#555',
|
||||||
|
marginTop: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
metaItem: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expiry options (hours) shown in the dropdown
|
||||||
|
const EXPIRY_OPTIONS = [1, 2, 4, 8, 12, 24];
|
||||||
|
|
||||||
export const PanelLeft = () => {
|
export const PanelLeft = () => {
|
||||||
const [code, set_code] = useState("")
|
const [code, setCode] = useState("")
|
||||||
|
const [settings, setSettings] = useState<UploadSettings>({maxDownloads: 3, expiresIn: 8 * 3600})
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
return <PanelLeftShow code={code} set_code={set_code} />
|
return <PanelLeftShow code={code} set_code={setCode} settings={settings}/>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PanelLeftUpload set_code={set_code}/>
|
return <PanelLeftUpload set_code={setCode} settings={settings} setSettings={setSettings}/>
|
||||||
};
|
};
|
||||||
|
|
||||||
const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_code}) => {
|
const PanelLeftUpload: React.FC<{
|
||||||
|
set_code: (code: string) => void;
|
||||||
|
settings: UploadSettings;
|
||||||
|
setSettings: (s: UploadSettings) => void;
|
||||||
|
}> = ({set_code, settings, setSettings}) => {
|
||||||
const style = useUploadStyle()
|
const style = useUploadStyle()
|
||||||
const {file, setFile} = useStore()
|
const {file, setFile} = useStore()
|
||||||
const {uploadFile, progress, loading} = useFileUpload();
|
const {uploadFile, progress, loading} = useFileUpload();
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [showAdv, setShowAdv] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/uauth/me').then(async res => {
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
const perms: string[] = json.data?.permissions ?? [];
|
|
||||||
setIsAdmin(perms.includes('user_manage'));
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function onFileSelect() {
|
function onFileSelect() {
|
||||||
// @ts-ignore
|
// @ts-expect-error no types for direct DOM query
|
||||||
document.querySelector('#real-file-input').click();
|
document.querySelector('#real-file-input').click();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,11 +272,8 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onFileUpload() {
|
async function onFileUpload() {
|
||||||
if (!file) {
|
if (!file) return;
|
||||||
return
|
const code = await uploadFile(file, settings)
|
||||||
}
|
|
||||||
|
|
||||||
const code = await uploadFile(file)
|
|
||||||
set_code(code)
|
set_code(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,37 +281,76 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
setFile(null)
|
setFile(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMaxDownloadsChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
let v = parseInt(e.target.value, 10);
|
||||||
|
if (isNaN(v) || v < 0) v = 0;
|
||||||
|
if (v > 999) v = 999;
|
||||||
|
setSettings({...settings, maxDownloads: v});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExpiryChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
setSettings({...settings, expiresIn: parseInt(e.target.value, 10)});
|
||||||
|
}
|
||||||
|
|
||||||
return <div className={style.container}>
|
return <div className={style.container}>
|
||||||
<div className={style.form}>
|
<div className={style.form}>
|
||||||
<h2 className={style.title}>上传文件</h2>
|
<h2 className={style.title}>上传文件</h2>
|
||||||
{
|
{!file && !loading && <UButton onClick={onFileSelect}>选择文件</UButton>}
|
||||||
!file && !loading &&
|
{file && !loading && <UButton onClick={onFileUpload}>上传文件</UButton>}
|
||||||
<UButton onClick={onFileSelect}>选择文件</UButton>
|
{loading && <UButton process={progress} loading={loading}>上传中</UButton>}
|
||||||
}
|
|
||||||
{
|
|
||||||
file && !loading &&
|
|
||||||
<UButton onClick={onFileUpload}>上传文件</UButton>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
loading &&
|
|
||||||
<UButton process={progress} loading={loading}>上传中</UButton>
|
|
||||||
}
|
|
||||||
<input type="file" className={style.file} id="real-file-input" onChange={onFileChange}/>
|
<input type="file" className={style.file} id="real-file-input" onChange={onFileChange}/>
|
||||||
{
|
{file && (
|
||||||
file &&
|
|
||||||
<div className={style.preview}>
|
<div className={style.preview}>
|
||||||
<div className={style.clean} onClick={onFileClean}>×</div>
|
<div className={style.clean} onClick={onFileClean}>×</div>
|
||||||
<div className={style.name}>{file.name}</div>
|
<div className={style.name}>{file.name}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<a href="/admin" className={style.adminLink}>管理控制台</a>
|
{/* Advanced settings toggle */}
|
||||||
|
<div className={style.advToggle} onClick={() => setShowAdv(v => !v)}>
|
||||||
|
<span>{showAdv ? '▾' : '▸'}</span>
|
||||||
|
<span>高级设置</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdv && (
|
||||||
|
<div className={style.advPanel}>
|
||||||
|
<div className={style.advRow}>
|
||||||
|
<span className={style.advLabel}>下载次数限制</span>
|
||||||
|
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={999}
|
||||||
|
className={style.advInput}
|
||||||
|
value={settings.maxDownloads}
|
||||||
|
onChange={onMaxDownloadsChange}
|
||||||
|
/>
|
||||||
|
<span className={style.advHint}>0 = 不限制</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.advRow}>
|
||||||
|
<span className={style.advLabel}>过期时间</span>
|
||||||
|
<select
|
||||||
|
className={style.advSelect}
|
||||||
|
value={settings.expiresIn}
|
||||||
|
onChange={onExpiryChange}
|
||||||
|
>
|
||||||
|
{EXPIRY_OPTIONS.map(h => (
|
||||||
|
<option key={h} value={h * 3600}>{h} 小时</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void }> = ({ code, set_code }) => {
|
const PanelLeftShow: React.FC<{
|
||||||
|
code: string;
|
||||||
|
set_code: (code: string) => void;
|
||||||
|
settings: UploadSettings;
|
||||||
|
}> = ({code, set_code, settings}) => {
|
||||||
const classes = useShowStyle();
|
const classes = useShowStyle();
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
@@ -265,30 +362,33 @@ const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const expiryHours = Math.round(settings.expiresIn / 3600);
|
||||||
|
const downloadLimit = settings.maxDownloads === 0 ? '不限' : `${settings.maxDownloads} 次`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.container}>
|
<div className={classes.container}>
|
||||||
|
|
||||||
<div className={classes.form}>
|
<div className={classes.form}>
|
||||||
<button
|
<button
|
||||||
className={classes.closeButton}
|
className={classes.closeButton}
|
||||||
onClick={() => set_code('')}
|
onClick={() => set_code('')}
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
>
|
>×</button>
|
||||||
×
|
<h2 className={classes.title}>上传成功!</h2>
|
||||||
</button>
|
|
||||||
<h2 className={classes.title}>
|
|
||||||
上传成功!
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className={classes.codeWrapper}>
|
<div className={classes.codeWrapper}>
|
||||||
<pre className={classes.pre}>
|
<pre className={classes.pre}>
|
||||||
<code>{code}</code>
|
<code>{code}</code>
|
||||||
<button className={classes.copyButton} onClick={handleCopy}>
|
<button className={classes.copyButton} onClick={handleCopy}>
|
||||||
一键复制
|
一键复制
|
||||||
</button>
|
</button>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.metaInfo}>
|
||||||
|
<span className={classes.metaItem}>下载限制:{downloadLimit}</span>
|
||||||
|
<span className={classes.metaItem}>有效期:{expiryHours} 小时</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ import {createUseStyles} from 'react-jss'
|
|||||||
import {PanelLeft} from "./component/panel-left.tsx";
|
import {PanelLeft} from "./component/panel-left.tsx";
|
||||||
import {PanelRight} from "./component/panel-right.tsx";
|
import {PanelRight} from "./component/panel-right.tsx";
|
||||||
import {PanelMid} from "./component/panel-mid.tsx";
|
import {PanelMid} from "./component/panel-mid.tsx";
|
||||||
|
import {NavBar} from "./component/nav-bar.tsx";
|
||||||
|
|
||||||
const useStyle = createUseStyles({
|
const useStyle = createUseStyles({
|
||||||
"@global": {
|
"@global": {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
|
wrapper: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
margin: 0,
|
flex: 1,
|
||||||
height: "100vh",
|
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "40% 20% 40%",
|
gridTemplateColumns: "40% 20% 40%",
|
||||||
|
overflow: 'hidden',
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
"@media (max-width: 768px)": {
|
||||||
gridTemplateColumns: "100%",
|
gridTemplateColumns: "100%",
|
||||||
@@ -24,9 +30,14 @@ const useStyle = createUseStyles({
|
|||||||
|
|
||||||
export const FileSharing = () => {
|
export const FileSharing = () => {
|
||||||
const style = useStyle()
|
const style = useStyle()
|
||||||
return <div className={style.container}>
|
return (
|
||||||
<PanelLeft />
|
<div className={style.wrapper}>
|
||||||
<PanelMid />
|
<NavBar />
|
||||||
<PanelRight/>
|
<div className={style.container}>
|
||||||
</div>
|
<PanelLeft />
|
||||||
};
|
<PanelMid />
|
||||||
|
<PanelRight />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ func Start(ctx context.Context) <-chan struct{} {
|
|||||||
api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles())
|
api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token management
|
||||||
|
{
|
||||||
|
api := app.Group("/api/token")
|
||||||
|
api.Get("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenList())
|
||||||
|
api.Post("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenCreate())
|
||||||
|
api.Delete("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenDelete())
|
||||||
|
}
|
||||||
|
|
||||||
|
// API v1 - token-authenticated file upload
|
||||||
|
app.Put("/api/v1/upload/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareAPIUpload())
|
||||||
|
|
||||||
// Frontend static files
|
// Frontend static files
|
||||||
app.Use(handler.ServeFrontendMiddleware())
|
app.Use(handler.ServeFrontendMiddleware())
|
||||||
|
|
||||||
|
|||||||
@@ -3,34 +3,38 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/loveuer/nf/nft/log"
|
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
|
"github.com/loveuer/nf/nft/log"
|
||||||
|
"github.com/loveuer/ushare/internal/model"
|
||||||
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type metaInfo struct {
|
type metaInfo struct {
|
||||||
f *os.File
|
f *os.File
|
||||||
name string
|
name string
|
||||||
create time.Time
|
create time.Time
|
||||||
last time.Time
|
last time.Time
|
||||||
size int64
|
size int64
|
||||||
cursor int64
|
cursor int64
|
||||||
user string
|
user string
|
||||||
|
maxDownloads int
|
||||||
|
expiresAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *metaInfo) generateMeta(code string) error {
|
func (m *metaInfo) generateMeta(code string) error {
|
||||||
content := fmt.Sprintf("filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s",
|
content := fmt.Sprintf(
|
||||||
m.name, m.create.UnixMilli(), m.size, m.user,
|
"filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s\nmax_downloads=%d\nexpires_at=%d\ndownloads=0",
|
||||||
|
m.name, m.create.UnixMilli(), m.size, m.user, m.maxDownloads, m.expiresAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
return os.WriteFile(opt.MetaPath(code), []byte(content), 0644)
|
return os.WriteFile(opt.MetaPath(code), []byte(content), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +50,19 @@ var (
|
|||||||
|
|
||||||
const letters = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const letters = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
|
||||||
func (m *meta) New(size int64, filename, ip string) (string, error) {
|
// New creates a new upload session.
|
||||||
|
// maxDownloads: 0 = unlimited; expiresIn: seconds from now (minimum opt.MinExpiresIn).
|
||||||
|
func (m *meta) New(size int64, filename, ip string, maxDownloads int, expiresIn int64) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
if expiresIn < opt.MinExpiresIn {
|
||||||
|
expiresIn = opt.DefaultExpiresIn
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxDownloads < 0 {
|
||||||
|
maxDownloads = 0
|
||||||
|
}
|
||||||
|
|
||||||
code, err := gonanoid.Generate(letters, opt.CodeLength)
|
code, err := gonanoid.Generate(letters, opt.CodeLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -66,7 +81,17 @@ func (m *meta) New(size int64, filename, ip string) (string, error) {
|
|||||||
m.Lock()
|
m.Lock()
|
||||||
defer m.Unlock()
|
defer m.Unlock()
|
||||||
|
|
||||||
m.m[code] = &metaInfo{f: f, name: filename, last: now, size: size, cursor: 0, create: now, user: ip}
|
m.m[code] = &metaInfo{
|
||||||
|
f: f,
|
||||||
|
name: filename,
|
||||||
|
last: now,
|
||||||
|
size: size,
|
||||||
|
cursor: 0,
|
||||||
|
create: now,
|
||||||
|
user: ip,
|
||||||
|
maxDownloads: maxDownloads,
|
||||||
|
expiresAt: now.Unix() + expiresIn,
|
||||||
|
}
|
||||||
|
|
||||||
return code, nil
|
return code, nil
|
||||||
}
|
}
|
||||||
@@ -100,6 +125,67 @@ func (m *meta) Write(code string, start, end int64, reader io.Reader) (total, cu
|
|||||||
return total, cursor, nil
|
return total, cursor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckAndIncrDownload reads the meta file, validates expiry and download limit,
|
||||||
|
// increments the download counter, and writes the meta file back.
|
||||||
|
// Returns the meta on success, or an error if the file is unavailable.
|
||||||
|
func (m *meta) CheckAndIncrDownload(code string) (*model.Meta, error) {
|
||||||
|
m.Lock()
|
||||||
|
defer m.Unlock()
|
||||||
|
|
||||||
|
metaPath := opt.MetaPath(code)
|
||||||
|
|
||||||
|
v := viper.New()
|
||||||
|
v.SetConfigFile(metaPath)
|
||||||
|
v.SetConfigType("env")
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
return nil, errors.New("文件不存在或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := new(model.Meta)
|
||||||
|
if err := v.Unmarshal(info); err != nil {
|
||||||
|
return nil, errors.New("文件元数据损坏")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if info.ExpiresAt > 0 && now > info.ExpiresAt {
|
||||||
|
// Clean up expired files
|
||||||
|
go func() {
|
||||||
|
_ = os.RemoveAll(opt.FilePath(code))
|
||||||
|
_ = os.RemoveAll(metaPath)
|
||||||
|
}()
|
||||||
|
return nil, errors.New("文件已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check download limit
|
||||||
|
if info.MaxDownloads > 0 && info.Downloads >= info.MaxDownloads {
|
||||||
|
return nil, errors.New("文件下载次数已达上限")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment downloads and write back
|
||||||
|
info.Downloads++
|
||||||
|
content := fmt.Sprintf(
|
||||||
|
"filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s\nmax_downloads=%d\nexpires_at=%d\ndownloads=%d",
|
||||||
|
info.Filename, info.CreatedAt, info.Size, info.Uploader,
|
||||||
|
info.MaxDownloads, info.ExpiresAt, info.Downloads,
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(metaPath, []byte(content), 0644); err != nil {
|
||||||
|
log.Warn("meta.CheckAndIncrDownload: write back failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this was the last allowed download, clean up after serving
|
||||||
|
if info.MaxDownloads > 0 && info.Downloads >= info.MaxDownloads {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
_ = os.RemoveAll(opt.FilePath(code))
|
||||||
|
_ = os.RemoveAll(metaPath)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *meta) Start(ctx context.Context) {
|
func (m *meta) Start(ctx context.Context) {
|
||||||
ticker := time.NewTicker(time.Minute)
|
ticker := time.NewTicker(time.Minute)
|
||||||
m.ctx = ctx
|
m.ctx = ctx
|
||||||
@@ -108,7 +194,7 @@ func (m *meta) Start(ctx context.Context) {
|
|||||||
log.Fatal("controller.MetaManager.Start: mkdir datapath failed, path = %s, err = %s", opt.Cfg.DataPath, err.Error())
|
log.Fatal("controller.MetaManager.Start: mkdir datapath failed, path = %s, err = %s", opt.Cfg.DataPath, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 2 分钟内没有继续上传的 part
|
// Clean uploads with no activity for 2 minutes
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -133,7 +219,7 @@ func (m *meta) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 清理一天前的文件
|
// Clean expired files by walking the data directory
|
||||||
go func() {
|
go func() {
|
||||||
if opt.Cfg.CleanInterval <= 0 {
|
if opt.Cfg.CleanInterval <= 0 {
|
||||||
log.Warn("meta.Clean: no clean interval set, plz clean manual!!!")
|
log.Warn("meta.Clean: no clean interval set, plz clean manual!!!")
|
||||||
@@ -148,12 +234,10 @@ func (m *meta) Start(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case now := <-ticker.C:
|
case now := <-ticker.C:
|
||||||
//log.Debug("meta.Clean: 开始清理过期文件 = %v", duration)
|
|
||||||
_ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error {
|
_ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -163,36 +247,33 @@ func (m *meta) Start(ctx context.Context) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.SetConfigFile(path)
|
v := viper.New()
|
||||||
viper.SetConfigType("env")
|
v.SetConfigFile(path)
|
||||||
if err = viper.ReadInConfig(); err != nil {
|
v.SetConfigType("env")
|
||||||
// todo log
|
if err = v.ReadInConfig(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mi := new(model.Meta)
|
mi := new(model.Meta)
|
||||||
|
if err = v.Unmarshal(mi); err != nil {
|
||||||
if err = viper.Unmarshal(mi); err != nil {
|
|
||||||
// todo log
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
code := strings.TrimPrefix(name, ".meta.")
|
code := strings.TrimPrefix(name, ".meta.")
|
||||||
|
|
||||||
|
// Remove if past explicit expiry
|
||||||
|
if mi.ExpiresAt > 0 && now.Unix() > mi.ExpiresAt {
|
||||||
|
log.Debug("controller.meta: file expired, code = %s", code)
|
||||||
|
_ = os.RemoveAll(opt.FilePath(code))
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove if past global clean interval
|
||||||
if now.Sub(time.UnixMilli(mi.CreatedAt)) > duration {
|
if now.Sub(time.UnixMilli(mi.CreatedAt)) > duration {
|
||||||
|
log.Debug("controller.meta: file out of date, code = %s", code)
|
||||||
log.Debug("controller.meta: file out of date, code = %s, user_key = %s", code, mi.Uploader)
|
_ = os.RemoveAll(opt.FilePath(code))
|
||||||
|
_ = os.RemoveAll(path)
|
||||||
if err = os.RemoveAll(opt.FilePath(code)); err != nil {
|
|
||||||
log.Warn("meta.Clean: remove file failed, file = %s, err = %s", opt.FilePath(code), err.Error())
|
|
||||||
}
|
|
||||||
if err = os.RemoveAll(path); err != nil {
|
|
||||||
log.Warn("meta.Clean: remove file failed, file = %s, err = %s", path, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
m.Lock()
|
|
||||||
delete(m.m, code)
|
|
||||||
m.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
105
internal/controller/token.go
Normal file
105
internal/controller/token.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/loveuer/ushare/internal/model"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/db"
|
||||||
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tokenManager struct{}
|
||||||
|
|
||||||
|
var TokenManager = &tokenManager{}
|
||||||
|
|
||||||
|
// List returns all tokens belonging to a user (token value is not exposed).
|
||||||
|
func (tm *tokenManager) List(userID uint) ([]model.Token, error) {
|
||||||
|
var tokens []model.Token
|
||||||
|
if err := db.Default.Session().Where("user_id = ?", userID).Order("created_at desc").Find(&tokens).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(err, "list tokens failed")
|
||||||
|
}
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create generates a new API token for the given user and returns the full token value (only shown once).
|
||||||
|
func (tm *tokenManager) Create(userID uint, name string) (*model.Token, string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, "", errors.New("token 名称不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawToken := model.TokenPrefix + tool.RandomString(32)
|
||||||
|
|
||||||
|
t := &model.Token{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
Token: rawToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Default.Session().Create(t).Error; err != nil {
|
||||||
|
return nil, "", errors.Wrap(err, "create token failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, rawToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a token by ID, only if it belongs to the given user.
|
||||||
|
func (tm *tokenManager) Delete(userID uint, tokenID uint) error {
|
||||||
|
result := db.Default.Session().
|
||||||
|
Where("id = ? AND user_id = ?", tokenID, userID).
|
||||||
|
Delete(&model.Token{})
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return errors.Wrap(result.Error, "delete token failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New("token 不存在或无权限删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify looks up a DB API token and returns a Session if valid.
|
||||||
|
func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
|
||||||
|
var t model.Token
|
||||||
|
if err := db.Default.Session().Where("token = ?", rawToken).First(&t).Error; err != nil {
|
||||||
|
return nil, errors.New("无效的 API Token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
|
||||||
|
return nil, errors.New("API Token 已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := db.Default.Session().First(&user, t.UserID).Error; err != nil {
|
||||||
|
return nil, errors.New("Token 关联用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Active {
|
||||||
|
return nil, errors.New("账号已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
var role model.Role
|
||||||
|
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
|
||||||
|
return nil, errors.New("账号角色异常")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used_at asynchronously
|
||||||
|
now := time.Now()
|
||||||
|
go db.Default.Session().Model(&t).Update("last_used_at", now) //nolint:errcheck
|
||||||
|
|
||||||
|
session := &model.Session{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: role.Name,
|
||||||
|
RoleLabel: role.Label,
|
||||||
|
Permissions: role.PermissionList(),
|
||||||
|
LoginAt: now.Unix(),
|
||||||
|
Token: rawToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
@@ -89,7 +89,6 @@ func (um *userManager) Login(username, password string) (*model.Session, error)
|
|||||||
user := new(model.User)
|
user := new(model.User)
|
||||||
if err := db.Default.Session().
|
if err := db.Default.Session().
|
||||||
Where("username = ? AND active = ?", username, true).
|
Where("username = ? AND active = ?", username, true).
|
||||||
Preload("Role").
|
|
||||||
First(user).Error; err != nil {
|
First(user).Error; err != nil {
|
||||||
return nil, errors.New("账号或密码错误")
|
return nil, errors.New("账号或密码错误")
|
||||||
}
|
}
|
||||||
@@ -98,12 +97,17 @@ func (um *userManager) Login(username, password string) (*model.Session, error)
|
|||||||
return nil, errors.New("账号或密码错误")
|
return nil, errors.New("账号或密码错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var role model.Role
|
||||||
|
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
|
||||||
|
return nil, errors.New("账号角色异常,请联系管理员")
|
||||||
|
}
|
||||||
|
|
||||||
session := &model.Session{
|
session := &model.Session{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Role: user.Role.Name,
|
Role: role.Name,
|
||||||
RoleLabel: user.Role.Label,
|
RoleLabel: role.Label,
|
||||||
Permissions: user.Role.PermissionList(),
|
Permissions: role.PermissionList(),
|
||||||
LoginAt: now.Unix(),
|
LoginAt: now.Unix(),
|
||||||
Token: tool.RandomString(32),
|
Token: tool.RandomString(32),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
"github.com/loveuer/nf"
|
||||||
"github.com/loveuer/nf/nft/log"
|
"github.com/loveuer/nf/nft/log"
|
||||||
@@ -12,14 +13,65 @@ import (
|
|||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// userResp is the JSON response shape for a user including role info,
|
||||||
|
// built manually at the business layer instead of relying on GORM associations.
|
||||||
|
type userResp struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
RoleID uint `json:"role_id"`
|
||||||
|
Role model.Role `json:"role"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUserResp(u model.User, r model.Role) userResp {
|
||||||
|
return userResp{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
RoleID: u.RoleID,
|
||||||
|
Role: r,
|
||||||
|
Active: u.Active,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
UpdatedAt: u.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func AdminListUsers() nf.HandlerFunc {
|
func AdminListUsers() nf.HandlerFunc {
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if err := db.Default.Session().Preload("Role").Find(&users).Error; err != nil {
|
if err := db.Default.Session().Find(&users).Error; err != nil {
|
||||||
log.Error("handler.AdminListUsers: %s", err.Error())
|
log.Error("handler.AdminListUsers: %s", err.Error())
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
|
||||||
}
|
}
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": users})
|
|
||||||
|
// Collect unique role IDs and query them in one shot
|
||||||
|
roleIDSet := make(map[uint]struct{})
|
||||||
|
for _, u := range users {
|
||||||
|
roleIDSet[u.RoleID] = struct{}{}
|
||||||
|
}
|
||||||
|
roleIDs := make([]uint, 0, len(roleIDSet))
|
||||||
|
for id := range roleIDSet {
|
||||||
|
roleIDs = append(roleIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles []model.Role
|
||||||
|
if err := db.Default.Session().Where("id IN ?", roleIDs).Find(&roles).Error; err != nil {
|
||||||
|
log.Error("handler.AdminListUsers: query roles: %s", err.Error())
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
|
||||||
|
}
|
||||||
|
|
||||||
|
roleMap := make(map[uint]model.Role, len(roles))
|
||||||
|
for _, r := range roles {
|
||||||
|
roleMap[r.ID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]userResp, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
resp = append(resp, toUserResp(u, roleMap[u.RoleID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": resp})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +109,11 @@ func AdminCreateUser() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var role model.Role
|
||||||
|
if err := db.Default.Session().First(&role, req.RoleID).Error; err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
|
||||||
|
}
|
||||||
|
|
||||||
user := &model.User{
|
user := &model.User{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Password: tool.NewPassword(req.Password),
|
Password: tool.NewPassword(req.Password),
|
||||||
@@ -69,11 +126,7 @@ func AdminCreateUser() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(*user, role)})
|
||||||
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +150,16 @@ func AdminUpdateUser() nf.HandlerFunc {
|
|||||||
|
|
||||||
session := c.Locals("user").(*model.Session)
|
session := c.Locals("user").(*model.Session)
|
||||||
|
|
||||||
user := new(model.User)
|
var user model.User
|
||||||
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
if err := db.Default.Session().First(&user, id).Error; err != nil {
|
||||||
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentRole model.Role
|
||||||
|
if err := db.Default.Session().First(¤tRole, user.RoleID).Error; err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
|
||||||
|
}
|
||||||
|
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
|
|
||||||
if req.RoleID != nil && *req.RoleID != user.RoleID {
|
if req.RoleID != nil && *req.RoleID != user.RoleID {
|
||||||
@@ -110,7 +168,7 @@ func AdminUpdateUser() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
|
||||||
}
|
}
|
||||||
// If demoting from admin, ensure at least one other active admin remains
|
// If demoting from admin, ensure at least one other active admin remains
|
||||||
if user.Role.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
|
if currentRole.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
|
||||||
var adminCount int64
|
var adminCount int64
|
||||||
db.Default.Session().Model(&model.User{}).
|
db.Default.Session().Model(&model.User{}).
|
||||||
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
||||||
@@ -120,13 +178,14 @@ func AdminUpdateUser() nf.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updates["role_id"] = *req.RoleID
|
updates["role_id"] = *req.RoleID
|
||||||
|
currentRole = newRole
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Active != nil && *req.Active != user.Active {
|
if req.Active != nil && *req.Active != user.Active {
|
||||||
if user.ID == session.UserID && !*req.Active {
|
if user.ID == session.UserID && !*req.Active {
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
|
||||||
}
|
}
|
||||||
if user.Role.Name == model.RoleAdmin && !*req.Active {
|
if currentRole.Name == model.RoleAdmin && !*req.Active {
|
||||||
var adminCount int64
|
var adminCount int64
|
||||||
db.Default.Session().Model(&model.User{}).
|
db.Default.Session().Model(&model.User{}).
|
||||||
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
||||||
@@ -149,16 +208,12 @@ func AdminUpdateUser() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Default.Session().Model(user).Updates(updates).Error; err != nil {
|
if err := db.Default.Session().Model(&user).Updates(updates).Error; err != nil {
|
||||||
log.Error("handler.AdminUpdateUser: %s", err.Error())
|
log.Error("handler.AdminUpdateUser: %s", err.Error())
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(user, currentRole)})
|
||||||
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,13 +229,18 @@ func AdminDeleteUser() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
|
||||||
}
|
}
|
||||||
|
|
||||||
user := new(model.User)
|
var user model.User
|
||||||
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
if err := db.Default.Session().First(&user, id).Error; err != nil {
|
||||||
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent deleting the last admin
|
// Prevent deleting the last admin: check via role name
|
||||||
if user.Role.Name == model.RoleAdmin {
|
var userRole model.Role
|
||||||
|
if err := db.Default.Session().First(&userRole, user.RoleID).Error; err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if userRole.Name == model.RoleAdmin {
|
||||||
var adminCount int64
|
var adminCount int64
|
||||||
db.Default.Session().Model(&model.User{}).
|
db.Default.Session().Model(&model.User{}).
|
||||||
Where("role_id = ? AND id != ?", user.RoleID, id).
|
Where("role_id = ? AND id != ?", user.RoleID, id).
|
||||||
@@ -190,7 +250,7 @@ func AdminDeleteUser() nf.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Default.Session().Delete(user).Error; err != nil {
|
if err := db.Default.Session().Delete(&user).Error; err != nil {
|
||||||
log.Error("handler.AdminDeleteUser: %s", err.Error())
|
log.Error("handler.AdminDeleteUser: %s", err.Error())
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
"github.com/loveuer/nf"
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
@@ -11,8 +12,12 @@ import (
|
|||||||
|
|
||||||
func AuthVerify() nf.HandlerFunc {
|
func AuthVerify() nf.HandlerFunc {
|
||||||
tokenFn := func(c *nf.Ctx) (token string) {
|
tokenFn := func(c *nf.Ctx) (token string) {
|
||||||
if token = c.Get("Authorization"); token != "" {
|
if raw := c.Get("Authorization"); raw != "" {
|
||||||
return
|
// Strip "Bearer " prefix if present
|
||||||
|
if strings.HasPrefix(raw, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(raw, "Bearer ")
|
||||||
|
}
|
||||||
|
return raw
|
||||||
}
|
}
|
||||||
token = c.Cookies("ushare")
|
token = c.Cookies("ushare")
|
||||||
return
|
return
|
||||||
@@ -24,7 +29,18 @@ func AuthVerify() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := controller.UserManager.Verify(token)
|
var (
|
||||||
|
session *model.Session
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
// API tokens have the "ust_" prefix; session tokens do not.
|
||||||
|
if strings.HasPrefix(token, model.TokenPrefix) {
|
||||||
|
session, err = controller.TokenManager.Verify(token)
|
||||||
|
} else {
|
||||||
|
session, err = controller.UserManager.Verify(token)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,42 +2,34 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/loveuer/nf"
|
|
||||||
"github.com/loveuer/nf/nft/log"
|
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/spf13/cast"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/loveuer/nf"
|
||||||
|
"github.com/loveuer/nf/nft/log"
|
||||||
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Fetch() nf.HandlerFunc {
|
func Fetch() nf.HandlerFunc {
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
code := c.Param("code")
|
code := c.Param("code")
|
||||||
log.Debug("handler.Fetch: code = %s", code)
|
log.Debug("handler.Fetch: code = %s", code)
|
||||||
info := new(model.Meta)
|
|
||||||
_, err := os.Stat(opt.MetaPath(code))
|
if _, err := os.Stat(opt.MetaPath(code)); err != nil {
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "文件不存在"})
|
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "文件不存在"})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendStatus(http.StatusInternalServerError)
|
return c.SendStatus(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.SetConfigFile(opt.MetaPath(code))
|
info, err := controller.MetaManager.CheckAndIncrDownload(code)
|
||||||
viper.SetConfigType("env")
|
if err != nil {
|
||||||
if err = viper.ReadInConfig(); err != nil {
|
return c.Status(http.StatusGone).JSON(map[string]string{"msg": err.Error()})
|
||||||
return c.SendStatus(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = viper.Unmarshal(info); err != nil {
|
|
||||||
return c.SendStatus(http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetHeader("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, info.Filename))
|
c.SetHeader("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, info.Filename))
|
||||||
@@ -59,7 +51,21 @@ func ShareNew() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: " + opt.HeaderSize})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: " + opt.HeaderSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := controller.MetaManager.New(size, filename, c.IP())
|
maxDownloads := opt.DefaultMaxDownloads
|
||||||
|
if v := c.Get(opt.HeaderMaxDownload); v != "" {
|
||||||
|
if n, err := cast.ToIntE(v); err == nil && n >= 0 {
|
||||||
|
maxDownloads = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresIn := int64(opt.DefaultExpiresIn)
|
||||||
|
if v := c.Get(opt.HeaderExpiresIn); v != "" {
|
||||||
|
if n, err := cast.ToInt64E(v); err == nil && n >= opt.MinExpiresIn {
|
||||||
|
expiresIn = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := controller.MetaManager.New(size, filename, c.IP(), maxDownloads, expiresIn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""})
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""})
|
||||||
}
|
}
|
||||||
@@ -116,3 +122,50 @@ func ShareUpload() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor})
|
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShareAPIUpload handles one-step file upload via API token.
|
||||||
|
// PUT /api/v1/upload/:filename
|
||||||
|
// Optional headers: X-Max-Downloads, X-Expires-In (seconds).
|
||||||
|
func ShareAPIUpload() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
filename := strings.TrimSpace(c.Param("filename"))
|
||||||
|
if filename == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "filename required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := cast.ToInt64E(c.Request.ContentLength)
|
||||||
|
if err != nil || size <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Content-Length header required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDownloads := opt.DefaultMaxDownloads
|
||||||
|
if v := c.Get(opt.HeaderMaxDownload); v != "" {
|
||||||
|
if n, err := cast.ToIntE(v); err == nil && n >= 0 {
|
||||||
|
maxDownloads = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresIn := int64(opt.DefaultExpiresIn)
|
||||||
|
if v := c.Get(opt.HeaderExpiresIn); v != "" {
|
||||||
|
if n, err := cast.ToInt64E(v); err == nil && n >= opt.MinExpiresIn {
|
||||||
|
expiresIn = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := controller.MetaManager.New(size, filename, c.IP(), maxDownloads, expiresIn)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "create upload failed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = controller.MetaManager.Write(code, 0, size-1, c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("handler.ShareAPIUpload: write error: %s", err)
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "upload failed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{
|
||||||
|
"status": 200,
|
||||||
|
"data": map[string]string{"code": code},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
85
internal/handler/token.go
Normal file
85
internal/handler/token.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/loveuer/nf"
|
||||||
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
|
"github.com/loveuer/ushare/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TokenList() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
session, ok := c.Locals("user").(*model.Session)
|
||||||
|
if !ok || session == nil {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := controller.TokenManager.List(session.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": tokens})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenCreate() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
session, ok := c.Locals("user").(*model.Session)
|
||||||
|
if !ok || session == nil {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req Req
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
|
||||||
|
}
|
||||||
|
|
||||||
|
t, rawToken, err := controller.TokenManager.Create(session.UserID, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{
|
||||||
|
"data": map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"name": t.Name,
|
||||||
|
"token": rawToken,
|
||||||
|
"created_at": t.CreatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenDelete() nf.HandlerFunc {
|
||||||
|
return func(c *nf.Ctx) error {
|
||||||
|
session, ok := c.Locals("user").(*model.Session)
|
||||||
|
if !ok || session == nil {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Req struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req Req
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ID == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "token id 不能为空"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := controller.TokenManager.Delete(session.UserID, req.ID); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
type Meta struct {
|
type Meta struct {
|
||||||
Filename string `json:"filename" mapstructure:"filename"`
|
Filename string `json:"filename" mapstructure:"filename"`
|
||||||
CreatedAt int64 `json:"created_at" mapstructure:"created_at"`
|
CreatedAt int64 `json:"created_at" mapstructure:"created_at"`
|
||||||
Size int64 `json:"size" mapstructure:"size"`
|
Size int64 `json:"size" mapstructure:"size"`
|
||||||
Uploader string `json:"uploader" mapstructure:"uploader"`
|
Uploader string `json:"uploader" mapstructure:"uploader"`
|
||||||
|
MaxDownloads int `json:"max_downloads" mapstructure:"max_downloads"`
|
||||||
|
ExpiresAt int64 `json:"expires_at" mapstructure:"expires_at"`
|
||||||
|
Downloads int `json:"downloads" mapstructure:"downloads"`
|
||||||
}
|
}
|
||||||
|
|||||||
18
internal/model/token.go
Normal file
18
internal/model/token.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Token is a personal API token for programmatic file upload.
|
||||||
|
// Token values are prefixed with "ust_" to distinguish them from session tokens.
|
||||||
|
type Token struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Token string `gorm:"uniqueIndex;not null" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenPrefix is the prefix for all API token values.
|
||||||
|
const TokenPrefix = "ust_"
|
||||||
@@ -8,7 +8,6 @@ type User struct {
|
|||||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||||
Password string `gorm:"not null" json:"-"`
|
Password string `gorm:"not null" json:"-"`
|
||||||
RoleID uint `gorm:"not null" json:"role_id"`
|
RoleID uint `gorm:"not null" json:"role_id"`
|
||||||
Role Role `gorm:"foreignKey:RoleID" json:"role"`
|
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ package opt
|
|||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Meta = ".meta."
|
Meta = ".meta."
|
||||||
HeaderSize = "X-File-Size"
|
HeaderSize = "X-File-Size"
|
||||||
CodeLength = 8
|
HeaderMaxDownload = "X-Max-Downloads"
|
||||||
|
HeaderExpiresIn = "X-Expires-In"
|
||||||
|
CodeLength = 8
|
||||||
|
|
||||||
|
// MinExpiresIn is the minimum allowed expiry in seconds (30s for testing).
|
||||||
|
MinExpiresIn = 30
|
||||||
|
// DefaultExpiresIn is the default expiry in seconds (8 hours).
|
||||||
|
DefaultExpiresIn = 8 * 3600
|
||||||
|
// DefaultMaxDownloads is the default max download count (0 = unlimited).
|
||||||
|
DefaultMaxDownloads = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
func FilePath(code string) string {
|
func FilePath(code string) string {
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -48,7 +48,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Debug("main: db initialized at %s", dbPath)
|
log.Debug("main: db initialized at %s", dbPath)
|
||||||
|
|
||||||
if err := db.Default.Migrate(&model.Role{}, &model.User{}); err != nil {
|
if err := db.Default.Migrate(&model.Role{}, &model.User{}, &model.Token{}); err != nil {
|
||||||
log.Fatal("main: db migrate failed: %s", err.Error())
|
log.Fatal("main: db migrate failed: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user