4 Commits

Author SHA1 Message Date
loveuer
a2589ee4b3 feat: add download limit and expiry control per upload (v0.7.0)
Some checks failed
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Has been cancelled
Backend:
- model/meta.go: add MaxDownloads, ExpiresAt, Downloads fields
- opt/var.go: add X-Max-Downloads, X-Expires-In header constants; MinExpiresIn=30s, defaults
- controller/meta.go: New() accepts maxDownloads+expiresIn; CheckAndIncrDownload() validates expiry/limit and increments counter atomically; periodic cleanup for expired files
- handler/share.go: Fetch uses CheckAndIncrDownload (returns 410 on expired/limit exceeded); ShareNew and ShareAPIUpload read X-Max-Downloads/X-Expires-In headers

Frontend:
- upload.ts: UploadSettings interface; pass X-Max-Downloads and X-Expires-In headers on upload init
- panel-left.tsx: collapsible "高级设置" panel with download count (0-999) and expiry (1-24h) controls; show settings summary on upload success card

🤖 Generated with [Qoder][https://qoder.com]
2026-03-02 01:49:37 -08:00
loveuer
050075d9c8 feat: add top navbar to /share page, move nav links out of upload zone
Some checks failed
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Has been cancelled
- Add NavBar component with app branding and conditional nav links
- NavBar shows "个人中心" for token_manage permission, "管理控制台" for user_manage
- Restructure share.tsx with flex column layout (NavBar + 3-column grid)
- Clean up panel-left.tsx: remove auth check, nav links, and unused styles

🤖 Generated with [Qoder][https://qoder.com]
2026-03-01 23:38:15 -08:00
loveuer
62e8acf757 refactor: remove GORM FK associations, handle relations in business layer (v0.6.1)
Some checks failed
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Has been cancelled
- Remove Role association field from User model
- Remove User association field from Token model
- controller/user.go: query Role separately after loading User
- controller/token.go: query User and Role with separate DB calls
- handler/admin.go: introduce userResp type, build role info manually;
  batch-load roles in AdminListUsers to avoid N+1

🤖 Generated with [Qoder][https://qoder.com]
2026-02-28 01:56:56 -08:00
loveuer
ef6347a8b4 feat: add token-based API access (v0.6.0)
Some checks failed
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Has been cancelled
- Add Token GORM model with UserID/Name/Token/LastUsedAt/ExpiresAt fields
- Add TokenManager controller: List/Create/Delete/Verify operations
- Add token HTTP handlers: list, create, revoke
- Update AuthVerify to support Bearer token auth; API tokens use "ust_" prefix to distinguish from session tokens
- Add one-step file upload endpoint: PUT /api/v1/upload/:filename (returns {"status":200,"data":{"code":"..."}})
- Add token management routes: GET/POST/DELETE /api/token
- Add /self page: personal center with account info, token management table, and curl usage guide
- Add "个人中心 / API Token" nav link for users with token_manage permission

🤖 Generated with [Qoder][https://qoder.com]
2026-02-28 01:32:08 -08:00
20 changed files with 1303 additions and 175 deletions

53
frontend/src/api/token.ts Normal file
View 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 || '删除失败');
}
},
};

View File

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

View File

@@ -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 />},
]) ])

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

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

@@ -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(&currentRole, 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": "删除失败"})
} }

View File

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

View File

@@ -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
View 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"})
}
}

View File

@@ -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
View 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_"

View File

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

View File

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

View File

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