Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e8acf757 | ||
|
|
ef6347a8b4 | ||
|
|
6286332896 | ||
|
|
38986be874 |
@@ -1,36 +0,0 @@
|
||||
run-name: build ushare
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build ushare:
|
||||
runs-on: debian
|
||||
steps:
|
||||
- name: prepare enviroment
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: prints info
|
||||
run: |
|
||||
date '+%Y-%m-%dT%H:%M:%S'
|
||||
whoami
|
||||
echo "Tag name = ${{ gitea.ref_name }}"
|
||||
pwd & ls -alsh .
|
||||
|
||||
- name: build image by docker build
|
||||
run: docker build -t gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }} .
|
||||
|
||||
- name: login repository
|
||||
run: echo ${{ secrets.DOCKER_REPOSITORY_PASSWORD }} | docker login --username loveuer --password-stdin gitea.loveuer.com/loveuer
|
||||
|
||||
- name: push image to repository
|
||||
run: docker push gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }}
|
||||
|
||||
clean:
|
||||
if: always()
|
||||
runs-on: debian
|
||||
steps:
|
||||
- name: clean docker config
|
||||
run: |
|
||||
rm -rf .docker.config.json
|
||||
22
.gitea_backup/workflows/build.yaml
Normal file
22
.gitea_backup/workflows/build.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
run-name: build ushare docker image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: debian
|
||||
steps:
|
||||
- name: prepare environment
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: print info
|
||||
run: |
|
||||
date '+%Y-%m-%dT%H:%M:%S'
|
||||
whoami
|
||||
echo "Tag = ${{ gitea.ref_name }}"
|
||||
pwd && ls -alsh .
|
||||
|
||||
- name: build docker image
|
||||
run: docker build -t ushare:${{ gitea.ref_name }} .
|
||||
90
.gitea_backup/workflows/release.yaml
Normal file
90
.gitea_backup/workflows/release.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
run-name: release ushare binaries
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: debian
|
||||
steps:
|
||||
- name: prepare environment
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: print info
|
||||
run: |
|
||||
date '+%Y-%m-%dT%H:%M:%S'
|
||||
echo "Tag = ${{ gitea.ref_name }}"
|
||||
echo "Repository = ${{ gitea.repository }}"
|
||||
echo "Server = ${{ gitea.server_url }}"
|
||||
|
||||
- name: build frontend
|
||||
run: |
|
||||
docker run --rm \
|
||||
--network host \
|
||||
-v "$(pwd)/frontend":/app/frontend \
|
||||
-w /app/frontend \
|
||||
node:20-alpine \
|
||||
sh -c "npm install -g pnpm --registry=https://registry.npmmirror.com \
|
||||
&& pnpm install --registry=https://registry.npmmirror.com \
|
||||
&& pnpm run build"
|
||||
mkdir -p internal/static/frontend
|
||||
cp -r frontend/dist internal/static/frontend/dist
|
||||
|
||||
- name: build binaries
|
||||
run: |
|
||||
mkdir -p dist
|
||||
docker run --rm \
|
||||
--network host \
|
||||
-v "$(pwd)":/workspace \
|
||||
-w /workspace \
|
||||
-e GOPROXY=https://goproxy.cn,direct \
|
||||
golang:alpine \
|
||||
sh -c "
|
||||
apk add --no-cache git && \
|
||||
go mod download && \
|
||||
for TARGET in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
|
||||
OS=\$(echo \$TARGET | cut -d/ -f1)
|
||||
ARCH=\$(echo \$TARGET | cut -d/ -f2)
|
||||
OUTPUT=\"dist/ushare-\${OS}-\${ARCH}\"
|
||||
[ \"\$OS\" = \"windows\" ] && OUTPUT=\"\${OUTPUT}.exe\"
|
||||
echo \">>> Building \${OUTPUT} ...\"
|
||||
CGO_ENABLED=0 GOOS=\$OS GOARCH=\$ARCH \
|
||||
go build -ldflags '-s -w' -o \$OUTPUT .
|
||||
done
|
||||
"
|
||||
ls -lh dist/
|
||||
|
||||
- name: create release
|
||||
run: |
|
||||
apt-get install -y -qq jq
|
||||
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
SERVER="${{ gitea.server_url }}"
|
||||
REPO="${{ gitea.repository }}"
|
||||
TOKEN="${{ secrets.GITEA_TOKEN }}"
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
"${SERVER}/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}")
|
||||
|
||||
echo "Release created: $(echo $RESPONSE | jq -r '.id')"
|
||||
echo "RELEASE_ID=$(echo $RESPONSE | jq -r '.id')" >> $GITHUB_ENV
|
||||
|
||||
- name: upload assets
|
||||
run: |
|
||||
SERVER="${{ gitea.server_url }}"
|
||||
REPO="${{ gitea.repository }}"
|
||||
TOKEN="${{ secrets.GITEA_TOKEN }}"
|
||||
|
||||
for FILE in dist/ushare-*; do
|
||||
FILENAME=$(basename "$FILE")
|
||||
echo ">>> Uploading ${FILENAME} ..."
|
||||
curl -sf -X POST \
|
||||
"${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${FILE}"
|
||||
done
|
||||
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 || '删除失败');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {FileSharing} from "./page/share/share.tsx";
|
||||
import {LocalSharing} from "./page/local/local.tsx";
|
||||
import {TestPage} from "./page/test/test.tsx";
|
||||
import {AdminPage} from "./page/admin/admin.tsx";
|
||||
import {SelfPage} from "./page/self/self.tsx";
|
||||
|
||||
const container = document.getElementById('root')
|
||||
const root = createRoot(container!)
|
||||
@@ -14,6 +15,7 @@ const router = createBrowserRouter([
|
||||
{path: "/login", element: <Login />},
|
||||
{path: "/share", element: <FileSharing />},
|
||||
{path: "/admin", element: <AdminPage />},
|
||||
{path: "/self", element: <SelfPage />},
|
||||
{path: "/test", element: <TestPage />},
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -70,6 +70,12 @@ const useUploadStyle = createUseStyles({
|
||||
opacity: 0.8,
|
||||
'&:hover': {opacity: 1, textDecoration: 'underline'},
|
||||
},
|
||||
navLinks: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
marginTop: '16px',
|
||||
},
|
||||
})
|
||||
|
||||
const useShowStyle = createUseStyles({
|
||||
@@ -190,6 +196,7 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
||||
const {file, setFile} = useStore()
|
||||
const {uploadFile, progress, loading} = useFileUpload();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [hasTokenPerm, setHasTokenPerm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/uauth/me').then(async res => {
|
||||
@@ -197,6 +204,7 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
||||
const json = await res.json();
|
||||
const perms: string[] = json.data?.permissions ?? [];
|
||||
setIsAdmin(perms.includes('user_manage'));
|
||||
setHasTokenPerm(perms.includes('token_manage'));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
@@ -249,6 +257,9 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
||||
{isAdmin && (
|
||||
<a href="/admin" className={style.adminLink}>管理控制台</a>
|
||||
)}
|
||||
{hasTokenPerm && (
|
||||
<a href="/self" className={style.adminLink}>个人中心 / API Token</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -50,6 +50,17 @@ func Start(ctx context.Context) <-chan struct{} {
|
||||
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
|
||||
app.Use(handler.ServeFrontendMiddleware())
|
||||
|
||||
|
||||
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)
|
||||
if err := db.Default.Session().
|
||||
Where("username = ? AND active = ?", username, true).
|
||||
Preload("Role").
|
||||
First(user).Error; err != nil {
|
||||
return nil, errors.New("账号或密码错误")
|
||||
}
|
||||
@@ -98,12 +97,17 @@ func (um *userManager) Login(username, password string) (*model.Session, error)
|
||||
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{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role.Name,
|
||||
RoleLabel: user.Role.Label,
|
||||
Permissions: user.Role.PermissionList(),
|
||||
Role: role.Name,
|
||||
RoleLabel: role.Label,
|
||||
Permissions: role.PermissionList(),
|
||||
LoginAt: now.Unix(),
|
||||
Token: tool.RandomString(32),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
@@ -12,14 +13,65 @@ import (
|
||||
"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 {
|
||||
return func(c *nf.Ctx) error {
|
||||
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())
|
||||
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": "用户名已存在"})
|
||||
}
|
||||
|
||||
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{
|
||||
Username: req.Username,
|
||||
Password: tool.NewPassword(req.Password),
|
||||
@@ -69,11 +126,7 @@ func AdminCreateUser() nf.HandlerFunc {
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
|
||||
}
|
||||
|
||||
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
||||
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(*user, role)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +150,16 @@ func AdminUpdateUser() nf.HandlerFunc {
|
||||
|
||||
session := c.Locals("user").(*model.Session)
|
||||
|
||||
user := new(model.User)
|
||||
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
||||
var user model.User
|
||||
if err := db.Default.Session().First(&user, id).Error; err != nil {
|
||||
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{}
|
||||
|
||||
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": "无效的角色"})
|
||||
}
|
||||
// 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
|
||||
db.Default.Session().Model(&model.User{}).
|
||||
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
||||
@@ -120,13 +178,14 @@ func AdminUpdateUser() nf.HandlerFunc {
|
||||
}
|
||||
}
|
||||
updates["role_id"] = *req.RoleID
|
||||
currentRole = newRole
|
||||
}
|
||||
|
||||
if req.Active != nil && *req.Active != user.Active {
|
||||
if user.ID == session.UserID && !*req.Active {
|
||||
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
|
||||
db.Default.Session().Model(&model.User{}).
|
||||
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": "没有需要更新的字段"})
|
||||
}
|
||||
|
||||
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())
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
|
||||
}
|
||||
|
||||
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
||||
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(user, currentRole)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,13 +229,18 @@ func AdminDeleteUser() nf.HandlerFunc {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
|
||||
}
|
||||
|
||||
user := new(model.User)
|
||||
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
||||
var user model.User
|
||||
if err := db.Default.Session().First(&user, id).Error; err != nil {
|
||||
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
||||
}
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if user.Role.Name == model.RoleAdmin {
|
||||
// Prevent deleting the last admin: check via role name
|
||||
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
|
||||
db.Default.Session().Model(&model.User{}).
|
||||
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())
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
@@ -11,8 +12,12 @@ import (
|
||||
|
||||
func AuthVerify() nf.HandlerFunc {
|
||||
tokenFn := func(c *nf.Ctx) (token string) {
|
||||
if token = c.Get("Authorization"); token != "" {
|
||||
return
|
||||
if raw := c.Get("Authorization"); raw != "" {
|
||||
// Strip "Bearer " prefix if present
|
||||
if strings.HasPrefix(raw, "Bearer ") {
|
||||
return strings.TrimPrefix(raw, "Bearer ")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
token = c.Cookies("ushare")
|
||||
return
|
||||
@@ -24,7 +29,18 @@ func AuthVerify() nf.HandlerFunc {
|
||||
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 {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
@@ -10,10 +15,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/spf13/viper"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Fetch() nf.HandlerFunc {
|
||||
@@ -116,3 +117,36 @@ func ShareUpload() nf.HandlerFunc {
|
||||
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
|
||||
// Accepts the raw file body and Content-Length header, returns the download code.
|
||||
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"})
|
||||
}
|
||||
|
||||
code, err := controller.MetaManager.New(size, filename, c.IP())
|
||||
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"})
|
||||
}
|
||||
}
|
||||
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"`
|
||||
Password string `gorm:"not null" json:"-"`
|
||||
RoleID uint `gorm:"not null" json:"role_id"`
|
||||
Role Role `gorm:"foreignKey:RoleID" json:"role"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
2
main.go
2
main.go
@@ -48,7 +48,7 @@ func main() {
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user