diff --git a/frontend/src/api/token.ts b/frontend/src/api/token.ts new file mode 100644 index 0000000..424993e --- /dev/null +++ b/frontend/src/api/token.ts @@ -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 => { + 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 => { + 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 => { + 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 || '删除失败'); + } + }, +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 06771d6..8bc242e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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: }, {path: "/share", element: }, {path: "/admin", element: }, + {path: "/self", element: }, {path: "/test", element: }, {path: "*", element: }, ]) diff --git a/frontend/src/page/self/self.tsx b/frontend/src/page/self/self.tsx new file mode 100644 index 0000000..8bffd89 --- /dev/null +++ b/frontend/src/page/self/self.tsx @@ -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(null); + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newTokenName, setNewTokenName] = useState(''); + const [creating, setCreating] = useState(false); + const [createdToken, setCreatedToken] = useState(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 ( +
+
+ +

个人中心

+
+ + {!loading && session && ( + <> + {/* User info card */} +
+

账号信息

+

+ 用户名:{session.username} +

+

+ 角色:{session.role_label} +

+
+ + {/* Token management card */} + {hasTokenPerm && ( +
+
+

API Token

+ setShowCreate(true)}>+ 新建 Token +
+ + + + + + + + + + + + {tokens.length === 0 ? ( + + + + ) : ( + tokens.map(t => ( + + + + + + + )) + )} + +
名称创建时间最后使用操作
+ 暂无 Token,点击「新建 Token」创建 +
{t.name}{formatDate(t.created_at)}{formatDate(t.last_used_at)} + +
+ + {/* Usage guide */} +
+
+

使用方式(curl 示例)

+
{`curl -H "Authorization: Bearer " \\
+     -T  \\
+     https:///api/v1/upload/`}
+

返回示例:

+
{`{"status":200,"data":{"code":"ABCD1234"}}`}
+

下载文件:

+
{`https:///ushare/`}
+
+
+
+ )} + + {!hasTokenPerm && ( +
+

当前角色无 Token 管理权限

+
+ )} + + )} + + {/* Create token dialog */} + {showCreate && ( +
setShowCreate(false)}> +
e.stopPropagation()}> +

新建 API Token

+ + setNewTokenName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreate()} + autoFocus + /> +
+ + + 创建 + +
+
+
+ )} + + {/* Newly created token display - shown only once */} + {createdToken && ( +
setCreatedToken(null)}> +
e.stopPropagation()}> +

Token 已创建

+

+ 请立即复制并妥善保存,Token 值仅显示一次,关闭后无法再次查看! +

+ +
{createdToken.token}
+
+ + +
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/page/share/component/panel-left.tsx b/frontend/src/page/share/component/panel-left.tsx index a2a9e05..db1f767 100644 --- a/frontend/src/page/share/component/panel-left.tsx +++ b/frontend/src/page/share/component/panel-left.tsx @@ -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 && ( 管理控制台 )} + {hasTokenPerm && ( + 个人中心 / API Token + )} } diff --git a/internal/api/api.go b/internal/api/api.go index f609358..d53185c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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()) diff --git a/internal/controller/token.go b/internal/controller/token.go new file mode 100644 index 0000000..e200ffe --- /dev/null +++ b/internal/controller/token.go @@ -0,0 +1,97 @@ +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 + err := db.Default.Session(). + Where("token = ?", rawToken). + Preload("User"). + Preload("User.Role"). + First(&t).Error + + if err != nil { + return nil, errors.New("无效的 API Token") + } + + if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) { + return nil, errors.New("API Token 已过期") + } + + // 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: t.User.ID, + Username: t.User.Username, + Role: t.User.Role.Name, + RoleLabel: t.User.Role.Label, + Permissions: t.User.Role.PermissionList(), + LoginAt: now.Unix(), + Token: rawToken, + } + + return session, nil +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 6cd424c..b51d091 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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()}) } diff --git a/internal/handler/share.go b/internal/handler/share.go index 3eb0df2..6ad226a 100644 --- a/internal/handler/share.go +++ b/internal/handler/share.go @@ -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}, + }) + } +} diff --git a/internal/handler/token.go b/internal/handler/token.go new file mode 100644 index 0000000..322a124 --- /dev/null +++ b/internal/handler/token.go @@ -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"}) + } +} diff --git a/internal/model/token.go b/internal/model/token.go new file mode 100644 index 0000000..74a1477 --- /dev/null +++ b/internal/model/token.go @@ -0,0 +1,19 @@ +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"` + User User `gorm:"foreignKey:UserID" json:"-"` + 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_" diff --git a/main.go b/main.go index a0c40b3..b3252f5 100644 --- a/main.go +++ b/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()) }