From 5f187bb5d6558538d3b9830a302efdeaef8c6c53 Mon Sep 17 00:00:00 2001 From: loveuer Date: Fri, 27 Feb 2026 19:40:31 -0800 Subject: [PATCH] feat: add user management system with roles and permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce SQLite persistence via GORM (stored at /.ushare.db) - Add Role model with two built-in roles: admin (all perms) and user (upload only) - Add three permissions: user_manage, upload, token_manage (reserved) - Rewrite UserManager: DB-backed login with in-memory session tokens - Auto-seed default roles and admin user on first startup - Add AuthPermission middleware for fine-grained permission checks - Add /api/uauth/me endpoint for current session info - Add /api/admin/* CRUD routes for user and role management - Add admin console page (/admin) with user table and role permissions view - Show admin console link in share page for users with user_manage permission 🤖 Generated with [Qoder][https://qoder.com] --- .gitea/workflows/build.yaml | 39 +- frontend/src/api/admin.ts | 89 +++ frontend/src/main.tsx | 2 + frontend/src/page/admin/admin.tsx | 533 ++++++++++++++++++ .../src/page/share/component/panel-left.tsx | 28 +- internal/api/api.go | 24 +- internal/controller/user.go | 136 +++-- internal/handler/admin.go | 211 +++++++ internal/handler/auth.go | 53 +- internal/model/role.go | 44 ++ internal/model/user.go | 29 +- internal/pkg/db/client.go | 4 + main.go | 20 + 13 files changed, 1119 insertions(+), 93 deletions(-) create mode 100644 frontend/src/api/admin.ts create mode 100644 frontend/src/page/admin/admin.tsx create mode 100644 internal/handler/admin.go create mode 100644 internal/model/role.go diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 4862d6b..938a4ea 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -6,28 +6,17 @@ on: jobs: build ushare: - runs-on: tencent-sg + runs-on: debian steps: - name: prepare enviroment uses: actions/checkout@v4 - - name: prints date - run: date '+%Y-%m-%dT%H:%M:%S' - - - name: print operator - run: whoami - - - name: print tag name - run: echo "Tag name = ${{ gitea.ref_name }}" - - - name: build prepare config + - name: prints info run: | - cat << EOF > .docker.config.json - ${{ secrets.DOCKER_CONFIG }} - EOF - - - name: print work dir and files - run: pwd & ls -alsh . + 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 }} . @@ -38,22 +27,10 @@ jobs: - name: push image to repository run: docker push gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }} -# - name: build by kaniko in docker -# run: | -# docker run --rm -v $(pwd):/workspace \ -# -v $(pwd)/.docker.config.json:/kaniko/.docker/config.json:ro \ -# alpine:latest \ -# ls -alsh /workspace -# gcr.io/kaniko-project/executor:latest \ -# --dockerfile=/workspace/Dockerfile \ -# --context=/workspace \ -# --destination=gitea.loveuer.com/loveuer/build/u-api:${{ gitea.ref_name }} \ -# --single-snapshot - clean: if: always() - runs-on: tencent-sg + runs-on: debian steps: - name: clean docker config run: | - rm -rf .docker.config.json \ No newline at end of file + rm -rf .docker.config.json diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..25324e5 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,89 @@ +export interface Role { + id: number; + name: string; + label: string; + permissions: string; + created_at: string; + updated_at: string; +} + +export interface AdminUser { + id: number; + username: string; + role_id: number; + role: Role; + active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateUserReq { + username: string; + password: string; + role_id: number; +} + +export interface UpdateUserReq { + role_id?: number; + active?: boolean; + password?: string; +} + +const jsonHeaders: HeadersInit = {'Content-Type': 'application/json'}; + +export const adminApi = { + listUsers: async (): Promise => { + const res = await fetch('/api/admin/users', {headers: jsonHeaders}); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.msg || '查询失败'); + } + return (await res.json()).data; + }, + + createUser: async (req: CreateUserReq): Promise => { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(req), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.msg || '创建失败'); + } + return (await res.json()).data; + }, + + updateUser: async (id: number, req: UpdateUserReq): Promise => { + const res = await fetch(`/api/admin/users/${id}`, { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify(req), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.msg || '更新失败'); + } + return (await res.json()).data; + }, + + deleteUser: async (id: number): Promise => { + const res = await fetch(`/api/admin/users/${id}`, { + method: 'DELETE', + headers: jsonHeaders, + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.msg || '删除失败'); + } + }, + + listRoles: async (): Promise => { + const res = await fetch('/api/admin/roles', {headers: jsonHeaders}); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.msg || '查询失败'); + } + return (await res.json()).data; + }, +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fdad411..06771d6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,12 +6,14 @@ import {Login} from "./page/login.tsx"; 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"; const container = document.getElementById('root') const root = createRoot(container!) const router = createBrowserRouter([ {path: "/login", element: }, {path: "/share", element: }, + {path: "/admin", element: }, {path: "/test", element: }, {path: "*", element: }, ]) diff --git a/frontend/src/page/admin/admin.tsx b/frontend/src/page/admin/admin.tsx new file mode 100644 index 0000000..1495cd9 --- /dev/null +++ b/frontend/src/page/admin/admin.tsx @@ -0,0 +1,533 @@ +import React, {useEffect, useState} from 'react'; +import {createUseStyles} from 'react-jss'; +import {adminApi, AdminUser, Role, UpdateUserReq} from '../../api/admin.ts'; +import {message} from '../../hook/message/u-message.tsx'; +import {UButton} from '../../component/button/u-button.tsx'; + +const PERM_LABELS: Record = { + user_manage: '用户管理', + upload: '上传文件', + token_manage: 'Token管理', +}; + +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', + }, + cardHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '16px', + }, + cardTitle: { + color: '#2c9678', + margin: 0, + fontSize: '16px', + fontWeight: 600, + }, + table: { + width: '100%', + borderCollapse: 'collapse', + backgroundColor: 'rgba(255,255,255,0.7)', + borderRadius: '8px', + overflow: 'hidden', + }, + th: { + backgroundColor: 'rgba(44,150,120,0.15)', + color: '#2c9678', + padding: '10px 14px', + textAlign: 'left', + fontSize: '13px', + fontWeight: 600, + }, + td: { + padding: '10px 14px', + borderBottom: '1px solid rgba(44,150,120,0.1)', + fontSize: '14px', + color: '#333', + }, + badgeActive: { + display: 'inline-block', + padding: '2px 10px', + borderRadius: '12px', + fontSize: '12px', + fontWeight: 500, + backgroundColor: '#c8e6c9', + color: '#2e7d32', + }, + badgeInactive: { + display: 'inline-block', + padding: '2px 10px', + borderRadius: '12px', + fontSize: '12px', + fontWeight: 500, + backgroundColor: '#ffcdd2', + color: '#c62828', + }, + actionBtn: { + background: 'transparent', + border: '1px solid #2c9678', + color: '#2c9678', + borderRadius: '4px', + padding: '4px 10px', + cursor: 'pointer', + fontSize: '12px', + marginRight: '6px', + transition: 'background-color 0.2s', + '&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'}, + }, + deleteBtn: { + background: 'transparent', + border: '1px solid #c62828', + color: '#c62828', + borderRadius: '4px', + padding: '4px 10px', + cursor: 'pointer', + fontSize: '12px', + transition: 'background-color 0.2s', + '&:hover': {backgroundColor: 'rgba(198,40,40,0.08)'}, + }, + permTag: { + display: 'inline-block', + backgroundColor: 'rgba(44,150,120,0.15)', + color: '#2c9678', + borderRadius: '10px', + padding: '2px 8px', + fontSize: '12px', + marginRight: '4px', + marginBottom: '2px', + }, + overlay: { + position: 'fixed', + top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + dialog: { + backgroundColor: '#C8E6C9', + boxShadow: '0 8px 32px rgba(44,150,120,0.2)', + borderRadius: '15px', + padding: '28px', + width: '400px', + maxWidth: '90vw', + boxSizing: 'border-box', + }, + dialogTitle: { + color: '#2c9678', + margin: '0 0 20px', + fontSize: '16px', + fontWeight: 600, + }, + formRow: { + marginBottom: '14px', + }, + label: { + display: 'block', + color: '#2c9678', + fontSize: '13px', + marginBottom: '5px', + fontWeight: 500, + }, + input: { + width: '100%', + padding: '9px 11px', + border: '2px solid #ddd', + borderRadius: '5px', + fontSize: '14px', + boxSizing: 'border-box', + background: 'rgba(255,255,255,0.8)', + transition: 'border-color 0.2s', + '&:focus': {outline: 'none', borderColor: '#2c9678'}, + }, + select: { + width: '100%', + padding: '9px 11px', + border: '2px solid #ddd', + borderRadius: '5px', + fontSize: '14px', + boxSizing: 'border-box', + background: 'rgba(255,255,255,0.8)', + transition: 'border-color 0.2s', + '&:focus': {outline: 'none', borderColor: '#2c9678'}, + }, + checkboxRow: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + dialogActions: { + display: 'flex', + gap: '10px', + justifyContent: 'flex-end', + marginTop: '20px', + }, + cancelBtn: { + background: 'transparent', + border: '2px solid #aaa', + color: '#666', + borderRadius: '5px', + padding: '8px 18px', + cursor: 'pointer', + fontSize: '14px', + transition: 'border-color 0.2s', + '&:hover': {borderColor: '#888'}, + }, + dangerBtn: { + backgroundColor: '#c62828', + color: 'white', + border: 'none', + borderRadius: '5px', + padding: '8px 18px', + cursor: 'pointer', + fontSize: '14px', + transition: 'background-color 0.2s', + '&:hover': {backgroundColor: '#b71c1c'}, + }, + emptyTip: { + textAlign: 'center', + color: '#999', + padding: '24px', + fontSize: '14px', + }, +}); + +type DialogMode = 'create' | 'edit'; + +interface DialogState { + open: boolean; + mode: DialogMode; + user?: AdminUser; + username: string; + password: string; + roleId: number; + active: boolean; +} + +const emptyDialog = (defaultRoleId = 0): DialogState => ({ + open: false, + mode: 'create', + username: '', + password: '', + roleId: defaultRoleId, + active: true, +}); + +export const AdminPage: React.FC = () => { + const classes = useStyle(); + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [dialog, setDialog] = useState(emptyDialog()); + const [saving, setSaving] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const loadData = async () => { + try { + const [u, r] = await Promise.all([adminApi.listUsers(), adminApi.listRoles()]); + setUsers(u ?? []); + setRoles(r ?? []); + } catch (e: unknown) { + message.error(e instanceof Error ? e.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetch('/api/uauth/me').then(res => { + if (res.status === 401) { + window.location.href = '/login?next=/admin'; + } else if (res.status === 403) { + message.error('权限不足'); + } else { + loadData(); + } + }).catch(() => { + window.location.href = '/login?next=/admin'; + }); + }, []); + + const openCreate = () => { + const defaultRoleId = roles.find(r => r.name === 'user')?.id ?? roles[0]?.id ?? 0; + setDialog({...emptyDialog(defaultRoleId), open: true, mode: 'create'}); + }; + + const openEdit = (user: AdminUser) => { + setDialog({ + open: true, + mode: 'edit', + user, + username: user.username, + password: '', + roleId: user.role_id, + active: user.active, + }); + }; + + const closeDialog = () => setDialog(emptyDialog()); + + const handleSave = async () => { + if (dialog.mode === 'create') { + if (!dialog.username.trim()) return message.warning('请输入用户名'); + if (!dialog.password) return message.warning('请输入密码'); + if (!dialog.roleId) return message.warning('请选择角色'); + } + setSaving(true); + try { + if (dialog.mode === 'create') { + await adminApi.createUser({ + username: dialog.username.trim(), + password: dialog.password, + role_id: dialog.roleId, + }); + message.success('创建成功'); + } else if (dialog.user) { + const req: UpdateUserReq = {}; + if (dialog.roleId !== dialog.user.role_id) req.role_id = dialog.roleId; + if (dialog.active !== dialog.user.active) req.active = dialog.active; + if (dialog.password) req.password = dialog.password; + await adminApi.updateUser(dialog.user.id, req); + message.success('更新成功'); + } + closeDialog(); + await loadData(); + } catch (e: unknown) { + message.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + await adminApi.deleteUser(deleteTarget.id); + message.success('删除成功'); + setDeleteTarget(null); + await loadData(); + } catch (e: unknown) { + message.error(e instanceof Error ? e.message : '删除失败'); + } + }; + + return ( +
+
+ +

管理控制台

+
+ + {/* Users */} +
+
+

用户管理

+ 添加用户 +
+ {loading ? ( +
加载中...
+ ) : ( + + + + + + + + + + + + + {users.length === 0 ? ( + + + + ) : users.map(u => ( + + + + + + + + + ))} + +
ID用户名角色状态创建时间操作
+
暂无用户
+
{u.id}{u.username}{u.role?.label ?? '-'} + + {u.active ? '启用' : '禁用'} + + + {new Date(u.created_at).toLocaleDateString('zh-CN')} + + + +
+ )} +
+ + {/* Roles */} +
+
+

角色权限

+
+ + + + + + + + + {roles.map(r => ( + + + + + ))} + +
角色权限
{r.label} + {r.permissions.split(',') + .map(p => p.trim()) + .filter(Boolean) + .map(p => ( + + {PERM_LABELS[p] ?? p} + + ))} +
+
+ + {/* Create / Edit Dialog */} + {dialog.open && ( +
e.target === e.currentTarget && closeDialog()}> +
+

+ {dialog.mode === 'create' ? '添加用户' : `编辑用户: ${dialog.user?.username}`} +

+ + {dialog.mode === 'create' && ( +
+ + setDialog(d => ({...d, username: e.target.value}))} + /> +
+ )} + +
+ + setDialog(d => ({...d, password: e.target.value}))} + /> +
+ +
+ + +
+ + {dialog.mode === 'edit' && ( +
+ +
+ setDialog(d => ({...d, active: e.target.checked}))} + /> + +
+
+ )} + +
+ + + {dialog.mode === 'create' ? '创建' : '保存'} + +
+
+
+ )} + + {/* Delete Confirm Dialog */} + {deleteTarget && ( +
e.target === e.currentTarget && setDeleteTarget(null)}> +
+

确认删除

+

+ 确定要删除用户 {deleteTarget.username} 吗?此操作不可撤销。 +

+
+ + +
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/page/share/component/panel-left.tsx b/frontend/src/page/share/component/panel-left.tsx index 37aaf0b..a2a9e05 100644 --- a/frontend/src/page/share/component/panel-left.tsx +++ b/frontend/src/page/share/component/panel-left.tsx @@ -1,6 +1,6 @@ import {createUseStyles} from "react-jss"; import {UButton} from "../../../component/button/u-button.tsx"; -import React, {useState} from "react"; +import React, {useEffect, useState} from "react"; import {useStore} from "../../../store/share.ts"; import {message} from "../../../hook/message/u-message.tsx"; import {useFileUpload} from "../../../api/upload.ts"; @@ -59,7 +59,17 @@ const useUploadStyle = createUseStyles({ borderRadius: '50%', cursor: 'pointer', '&:hover': {} - } + }, + adminLink: { + display: 'block', + textAlign: 'center', + marginTop: '16px', + color: '#2c9678', + fontSize: '12px', + textDecoration: 'none', + opacity: 0.8, + '&:hover': {opacity: 1, textDecoration: 'underline'}, + }, }) const useShowStyle = createUseStyles({ @@ -179,6 +189,17 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod const style = useUploadStyle() const {file, setFile} = useStore() const {uploadFile, progress, loading} = useFileUpload(); + const [isAdmin, setIsAdmin] = 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() { // @ts-ignore @@ -225,6 +246,9 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
{file.name}
} + {isAdmin && ( + 管理控制台 + )} } diff --git a/internal/api/api.go b/internal/api/api.go index 6e9ffe0..f609358 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -9,6 +9,7 @@ import ( "github.com/loveuer/nf/nft/log" "github.com/loveuer/nf/nft/tool" "github.com/loveuer/ushare/internal/handler" + "github.com/loveuer/ushare/internal/model" "github.com/loveuer/ushare/internal/opt" ) @@ -19,11 +20,16 @@ func Start(ctx context.Context) <-chan struct{} { return c.SendStatus(http.StatusOK) }) - app.Get("/ushare/:code", handler.Fetch()) - app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.ShareNew()) // 获取上传 code, 分片大小 - app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口 + // Auth app.Post("/api/uauth/login", handler.AuthLogin()) + app.Get("/api/uauth/me", handler.AuthVerify(), handler.AuthMe()) + // File sharing + app.Get("/ushare/:code", handler.Fetch()) + app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareNew()) + app.Post("/api/ushare/:code", handler.ShareUpload()) + + // Local sharing (WebRTC signaling) { api := app.Group("/api/ulocal") api.Post("/register", handler.LocalRegister()) @@ -34,7 +40,17 @@ func Start(ctx context.Context) <-chan struct{} { api.Get("/ws", handler.LocalWS()) } - // 静态文件服务 - 作为中间件处理 + // Admin + { + api := app.Group("/api/admin") + api.Get("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListUsers()) + api.Post("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminCreateUser()) + api.Put("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminUpdateUser()) + api.Delete("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminDeleteUser()) + api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles()) + } + + // Frontend static files app.Use(handler.ServeFrontendMiddleware()) ready := make(chan struct{}) diff --git a/internal/controller/user.go b/internal/controller/user.go index 0462a4e..d56d970 100644 --- a/internal/controller/user.go +++ b/internal/controller/user.go @@ -2,75 +2,149 @@ package controller import ( "context" - "github.com/loveuer/ushare/internal/model" - "github.com/loveuer/ushare/internal/opt" - "github.com/loveuer/ushare/internal/pkg/tool" - "github.com/pkg/errors" + "strings" "sync" "time" + + "github.com/loveuer/nf/nft/log" + "github.com/loveuer/ushare/internal/model" + "github.com/loveuer/ushare/internal/opt" + "github.com/loveuer/ushare/internal/pkg/db" + "github.com/loveuer/ushare/internal/pkg/tool" + "github.com/pkg/errors" ) type userManager struct { sync.Mutex - ctx context.Context - um map[string]*model.User + ctx context.Context + sessions map[string]*model.Session } -func (um *userManager) Login(username string, password string) (*model.User, error) { - var ( - now = time.Now() - ) +var UserManager = &userManager{ + sessions: make(map[string]*model.Session), +} - if username != opt.Cfg.Username { - return nil, errors.New("账号或密码错误") +func (um *userManager) seed(ctx context.Context) error { + // Seed default roles if they don't exist + defaultRoles := []model.Role{ + { + Name: model.RoleAdmin, + Label: "管理员", + Permissions: strings.Join([]string{model.PermUserManage, model.PermUpload, model.PermTokenManage}, ","), + }, + { + Name: model.RoleUser, + Label: "用户", + Permissions: model.PermUpload, + }, } - if !tool.ComparePassword(password, opt.Cfg.Password) { - return nil, errors.New("账号或密码错误") + for i := range defaultRoles { + role := &defaultRoles[i] + var existing model.Role + if err := db.Default.Session(ctx).Where("name = ?", role.Name).First(&existing).Error; err != nil { + if err := db.Default.Session(ctx).Create(role).Error; err != nil { + return errors.Wrap(err, "seed role failed") + } + log.Debug("controller.userManager.seed: created role %s", role.Name) + } } - op := &model.User{ - Id: 1, + // Seed default admin user only if no users exist + var count int64 + db.Default.Session(ctx).Model(&model.User{}).Count(&count) + if count > 0 { + return nil + } + + var adminRole model.Role + if err := db.Default.Session(ctx).Where("name = ?", model.RoleAdmin).First(&adminRole).Error; err != nil { + return errors.Wrap(err, "get admin role failed") + } + + username := opt.Cfg.Username + if username == "" { + username = "admin" + } + + // opt.Cfg.Password is already hashed by opt.Init(); store it directly. + adminUser := &model.User{ Username: username, - LoginAt: now.Unix(), - Token: tool.RandomString(32), + Password: opt.Cfg.Password, + RoleID: adminRole.ID, + Active: true, + } + + if err := db.Default.Session(ctx).Create(adminUser).Error; err != nil { + return errors.Wrap(err, "seed admin user failed") + } + + log.Debug("controller.userManager.seed: created admin user %s", username) + return nil +} + +func (um *userManager) Login(username, password string) (*model.Session, error) { + now := time.Now() + + 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("账号或密码错误") + } + + if !tool.ComparePassword(password, user.Password) { + 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(), + LoginAt: now.Unix(), + Token: tool.RandomString(32), } um.Lock() defer um.Unlock() - um.um[op.Token] = op + um.sessions[session.Token] = session - return op, nil + return session, nil } -func (um *userManager) Verify(token string) (*model.User, error) { +func (um *userManager) Verify(token string) (*model.Session, error) { um.Lock() defer um.Unlock() - op, ok := um.um[token] + session, ok := um.sessions[token] if !ok { return nil, errors.New("未登录或凭证已失效, 请重新登录") } - return op, nil + return session, nil } func (um *userManager) Start(ctx context.Context) { um.ctx = ctx + if err := um.seed(ctx); err != nil { + log.Fatal("controller.userManager.Start: seed failed: %s", err.Error()) + } + go func() { - ticker := time.NewTicker(time.Minute) - for { select { case <-um.ctx.Done(): return case now := <-ticker.C: um.Lock() - for _, op := range um.um { - if now.Sub(time.UnixMilli(op.LoginAt)) > 8*time.Hour { - delete(um.um, op.Token) + for token, session := range um.sessions { + if now.Unix()-session.LoginAt > 8*3600 { + delete(um.sessions, token) } } um.Unlock() @@ -78,9 +152,3 @@ func (um *userManager) Start(ctx context.Context) { } }() } - -var ( - UserManager = &userManager{ - um: make(map[string]*model.User), - } -) diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..81a508b --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,211 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/loveuer/nf" + "github.com/loveuer/nf/nft/log" + "github.com/loveuer/ushare/internal/model" + "github.com/loveuer/ushare/internal/pkg/db" + "github.com/loveuer/ushare/internal/pkg/tool" + "github.com/spf13/cast" +) + +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 { + 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}) + } +} + +func AdminCreateUser() nf.HandlerFunc { + return func(c *nf.Ctx) error { + type Req struct { + Username string `json:"username"` + Password string `json:"password"` + RoleID uint `json:"role_id"` + } + + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"}) + } + + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名不能为空"}) + } + if req.Password == "" { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "密码不能为空"}) + } + if req.RoleID == 0 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "角色不能为空"}) + } + + if err := tool.CheckPassword(req.Password); err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()}) + } + + var count int64 + db.Default.Session().Model(&model.User{}).Where("username = ?", req.Username).Count(&count) + if count > 0 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"}) + } + + user := &model.User{ + Username: req.Username, + Password: tool.NewPassword(req.Password), + RoleID: req.RoleID, + Active: true, + } + + if err := db.Default.Session().Create(user).Error; err != nil { + log.Error("handler.AdminCreateUser: %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.AdminCreateUser: preload role: %s", err.Error()) + } + + return c.Status(http.StatusOK).JSON(map[string]any{"data": user}) + } +} + +func AdminUpdateUser() nf.HandlerFunc { + return func(c *nf.Ctx) error { + type Req struct { + RoleID *uint `json:"role_id"` + Active *bool `json:"active"` + Password string `json:"password"` + } + + id, err := cast.ToUintE(c.Param("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"}) + } + + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"}) + } + + session := c.Locals("user").(*model.Session) + + user := new(model.User) + if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"}) + } + + updates := map[string]any{} + + if req.RoleID != nil && *req.RoleID != user.RoleID { + var newRole model.Role + if err := db.Default.Session().First(&newRole, *req.RoleID).Error; err != nil { + 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 { + var adminCount int64 + db.Default.Session().Model(&model.User{}). + Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id). + Count(&adminCount) + if adminCount == 0 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法更改角色: 系统中至少需要一个管理员"}) + } + } + updates["role_id"] = *req.RoleID + } + + 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 { + var adminCount int64 + db.Default.Session().Model(&model.User{}). + Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id). + Count(&adminCount) + if adminCount == 0 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法禁用: 系统中至少需要一个启用的管理员"}) + } + } + updates["active"] = *req.Active + } + + if req.Password != "" { + if err := tool.CheckPassword(req.Password); err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()}) + } + updates["password"] = tool.NewPassword(req.Password) + } + + if len(updates) == 0 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"}) + } + + 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}) + } +} + +func AdminDeleteUser() nf.HandlerFunc { + return func(c *nf.Ctx) error { + id, err := cast.ToUintE(c.Param("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"}) + } + + session := c.Locals("user").(*model.Session) + if session.UserID == id { + 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 { + return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"}) + } + + // Prevent deleting the last admin + if user.Role.Name == model.RoleAdmin { + var adminCount int64 + db.Default.Session().Model(&model.User{}). + Where("role_id = ? AND id != ?", user.RoleID, id). + Count(&adminCount) + if adminCount == 0 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法删除最后一个管理员"}) + } + } + + 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": "删除失败"}) + } + + return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"}) + } +} + +func AdminListRoles() nf.HandlerFunc { + return func(c *nf.Ctx) error { + var roles []model.Role + if err := db.Default.Session().Find(&roles).Error; err != nil { + log.Error("handler.AdminListRoles: %s", err.Error()) + return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"}) + } + return c.Status(http.StatusOK).JSON(map[string]any{"data": roles}) + } +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index d62d990..6cd424c 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -2,11 +2,11 @@ package handler import ( "fmt" + "net/http" + "github.com/loveuer/nf" "github.com/loveuer/ushare/internal/controller" "github.com/loveuer/ushare/internal/model" - "github.com/loveuer/ushare/internal/opt" - "net/http" ) func AuthVerify() nf.HandlerFunc { @@ -14,33 +14,54 @@ func AuthVerify() nf.HandlerFunc { if token = c.Get("Authorization"); token != "" { return } - token = c.Cookies("ushare") - return } return func(c *nf.Ctx) error { - if opt.Cfg.Username == "" || opt.Cfg.Password == "" { - return c.Next() - } - token := tokenFn(c) if token == "" { return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"}) } - op, err := controller.UserManager.Verify(token) + session, err := controller.UserManager.Verify(token) if err != nil { return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()}) } - c.Locals("user", op) + c.Locals("user", session) return c.Next() } } +func AuthPermission(perm string) 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"}) + } + + for _, p := range session.Permissions { + if p == perm { + return c.Next() + } + } + + return c.Status(http.StatusForbidden).JSON(map[string]string{"error": "forbidden", "msg": "权限不足"}) + } +} + +func AuthMe() 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"}) + } + return c.Status(http.StatusOK).JSON(map[string]any{"data": session}) + } +} + func AuthLogin() nf.HandlerFunc { return func(c *nf.Ctx) error { type Req struct { @@ -49,22 +70,22 @@ func AuthLogin() nf.HandlerFunc { } var ( - err error - req Req - op *model.User + err error + req Req + session *model.Session ) if err = c.BodyParser(&req); err != nil { return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"}) } - if op, err = controller.UserManager.Login(req.Username, req.Password); err != nil { + if session, err = controller.UserManager.Login(req.Username, req.Password); err != nil { return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()}) } - header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", op.Token, 8*3600) + header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", session.Token, 8*3600) c.SetHeader("Set-Cookie", header) - return c.Status(http.StatusOK).JSON(map[string]any{"data": op}) + return c.Status(http.StatusOK).JSON(map[string]any{"data": session}) } } diff --git a/internal/model/role.go b/internal/model/role.go new file mode 100644 index 0000000..f0b942b --- /dev/null +++ b/internal/model/role.go @@ -0,0 +1,44 @@ +package model + +import ( + "strings" + "time" +) + +const ( + PermUserManage = "user_manage" + PermUpload = "upload" + PermTokenManage = "token_manage" + + RoleAdmin = "admin" + RoleUser = "user" +) + +type Role struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"uniqueIndex;not null" json:"name"` + Label string `gorm:"not null" json:"label"` + Permissions string `gorm:"not null" json:"permissions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (r *Role) HasPermission(perm string) bool { + for _, p := range r.PermissionList() { + if p == perm { + return true + } + } + return false +} + +func (r *Role) PermissionList() []string { + list := make([]string, 0) + for _, p := range strings.Split(r.Permissions, ",") { + p = strings.TrimSpace(p) + if p != "" { + list = append(list, p) + } + } + return list +} diff --git a/internal/model/user.go b/internal/model/user.go index ddc7a03..0eb2fa6 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -1,10 +1,27 @@ package model +import "time" + +// User is the GORM database model for persistent user storage. type User struct { - Id int `json:"id"` - Username string `json:"username"` - Key string `json:"key"` - Password string `json:"-"` - LoginAt int64 `json:"login_at"` - Token string `json:"token"` + ID uint `gorm:"primarykey" json:"id"` + 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"` +} + +// Session is the in-memory representation of an authenticated user. +// It is created on login and stored in the UserManager session map. +type Session struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + RoleLabel string `json:"role_label"` + Permissions []string `json:"permissions"` + LoginAt int64 `json:"login_at"` + Token string `json:"token"` } diff --git a/internal/pkg/db/client.go b/internal/pkg/db/client.go index 849d199..88bcbee 100644 --- a/internal/pkg/db/client.go +++ b/internal/pkg/db/client.go @@ -46,6 +46,10 @@ func (c *Client) Session(ctxs ...context.Context) *gorm.DB { return session } +func (c *Client) Migrate(models ...interface{}) error { + return c.cli.AutoMigrate(models...) +} + func (c *Client) Close() { d, _ := c.cli.DB() d.Close() diff --git a/main.go b/main.go index e4281e8..a0c40b3 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,15 @@ package main import ( "context" "flag" + "os" + "path/filepath" + "github.com/loveuer/nf/nft/log" "github.com/loveuer/ushare/internal/api" "github.com/loveuer/ushare/internal/controller" + "github.com/loveuer/ushare/internal/model" "github.com/loveuer/ushare/internal/opt" + "github.com/loveuer/ushare/internal/pkg/db" "github.com/loveuer/ushare/internal/pkg/tool" "os/signal" "syscall" @@ -32,6 +37,21 @@ func main() { defer cancel() opt.Init(ctx) + + if err := os.MkdirAll(opt.Cfg.DataPath, 0755); err != nil { + log.Fatal("main: create data path failed: %s", err.Error()) + } + + dbPath := filepath.Join(opt.Cfg.DataPath, ".ushare.db") + if err := db.Init(ctx, "sqlite::"+dbPath); err != nil { + log.Fatal("main: init db failed: %s", err.Error()) + } + log.Debug("main: db initialized at %s", dbPath) + + if err := db.Default.Migrate(&model.Role{}, &model.User{}); err != nil { + log.Fatal("main: db migrate failed: %s", err.Error()) + } + controller.UserManager.Start(ctx) controller.MetaManager.Start(ctx) controller.RoomController.Start(ctx)