feat: add file management and branding
Release / deploy (push) Successful in 2m26s

This commit is contained in:
loveuer
2026-09-10 20:40:57 -07:00
parent 8ca8341d7c
commit 3907410354
9 changed files with 209 additions and 36 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# ufile
一个单二进制运行的轻量文件快传服务。支持多用户和三级权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。
一个单二进制运行的轻量文件快传服务。支持多用户权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。文件管理员可以查看并删除全部当前有效文件。
## 构建
+75 -14
View File
@@ -65,13 +65,14 @@ func newApp(dbPath, dataDir string, web fs.FS) (*App, string, error) {
}
func (a *App) ensureAdmin() (string, error) {
var count int64
if err := a.db.Model(&User{}).Count(&count).Error; err != nil {
var admin User
if err := a.db.First(&admin, 1).Error; err == nil {
return "", a.db.Model(&admin).Updates(map[string]any{
"can_manage_users": true, "can_manage_files": true, "can_upload": true, "can_advanced_upload": true,
}).Error
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
if count != 0 {
return "", nil
}
password, err := randomToken(18)
if err != nil {
return "", err
@@ -80,7 +81,7 @@ func (a *App) ensureAdmin() (string, error) {
if err != nil {
return "", err
}
admin := User{ID: 1, Username: "admin", PasswordHash: string(hash), CanManageUsers: true, CanUpload: true, CanAdvancedUpload: true, MustChangePassword: true}
admin = User{ID: 1, Username: "admin", PasswordHash: string(hash), CanManageUsers: true, CanManageFiles: true, CanUpload: true, CanAdvancedUpload: true, MustChangePassword: true}
if err := a.db.Create(&admin).Error; err != nil {
return "", err
}
@@ -96,7 +97,9 @@ func (a *App) router() *gin.Engine {
r.GET("/api/auth/me", a.auth(false), a.me)
r.PUT("/api/auth/password", a.auth(false), a.changePassword)
r.GET("/api/pickup/:hash", a.verifyPickup)
r.GET("/api/files", a.auth(true), a.manageFiles, a.listManagedFiles)
r.POST("/api/files", a.auth(true), a.uploadFile)
r.DELETE("/api/files/:id", a.auth(true), a.manageFiles, a.deleteManagedFile)
r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers)
r.POST("/api/users", a.auth(true), a.manageUsers, a.createUser)
r.PUT("/api/users/:id", a.auth(true), a.manageUsers, a.updateUser)
@@ -146,10 +149,21 @@ func (a *App) manageUsers(c *gin.Context) {
c.Next()
}
func permissionsWithin(actor *User, canManageUsers, canUpload, canAdvancedUpload bool) bool {
func (a *App) manageFiles(c *gin.Context) {
if !currentUser(c).CanManageFiles {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "没有文件管理权限"})
return
}
c.Next()
}
func permissionsWithin(actor *User, canManageUsers, canManageFiles, canUpload, canAdvancedUpload bool) bool {
if canManageUsers && !actor.CanManageUsers {
return false
}
if canManageFiles && !actor.CanManageFiles {
return false
}
if canAdvancedUpload && !actor.CanAdvancedUpload {
return false
}
@@ -157,11 +171,11 @@ func permissionsWithin(actor *User, canManageUsers, canUpload, canAdvancedUpload
}
func canManageTarget(actor, target *User) bool {
return permissionsWithin(actor, target.CanManageUsers, target.CanUpload, target.CanAdvancedUpload)
return permissionsWithin(actor, target.CanManageUsers, target.CanManageFiles, target.CanUpload, target.CanAdvancedUpload)
}
func requireAssignablePermissions(c *gin.Context, canManageUsers, canUpload, canAdvancedUpload bool) bool {
if permissionsWithin(currentUser(c), canManageUsers, canUpload, canAdvancedUpload) {
func requireAssignablePermissions(c *gin.Context, canManageUsers, canManageFiles, canUpload, canAdvancedUpload bool) bool {
if permissionsWithin(currentUser(c), canManageUsers, canManageFiles, canUpload, canAdvancedUpload) {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "不能授予自己不具备的权限"})
@@ -308,6 +322,51 @@ func parseDownloads(value string) (*int, bool) {
return &n, true
}
func (a *App) listManagedFiles(c *gin.Context) {
var files []SharedFile
if err := a.db.Where("ready = ? AND (expires_at IS NULL OR expires_at > ?)", true, a.now()).Order("created_at DESC").Find(&files).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件列表失败"})
return
}
ownerIDs := make([]uint, 0, len(files))
for _, file := range files {
ownerIDs = append(ownerIDs, file.OwnerID)
}
owners := make(map[uint]string, len(ownerIDs))
if len(ownerIDs) > 0 {
var users []User
if err := a.db.Select("id", "username").Where("id IN ?", ownerIDs).Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件列表失败"})
return
}
for _, user := range users {
owners[user.ID] = user.Username
}
}
result := make([]fileResponse, 0, len(files))
for _, file := range files {
item := presentFile(file)
item.OwnerUsername = owners[file.OwnerID]
result = append(result, item)
}
c.JSON(http.StatusOK, result)
}
func (a *App) deleteManagedFile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
return
}
var file SharedFile
if err := a.db.First(&file, uint(id)).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
return
}
a.removeFile(file)
c.Status(http.StatusNoContent)
}
func (a *App) uploadFile(c *gin.Context) {
user := currentUser(c)
if !user.CanUpload && !user.CanAdvancedUpload {
@@ -672,6 +731,7 @@ func (a *App) createUser(c *gin.Context) {
var req struct {
Username string `json:"username"`
CanManageUsers bool `json:"canManageUsers"`
CanManageFiles bool `json:"canManageFiles"`
CanUpload bool `json:"canUpload"`
CanAdvancedUpload bool `json:"canAdvancedUpload"`
}
@@ -679,7 +739,7 @@ func (a *App) createUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"})
return
}
if !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) {
if !requireAssignablePermissions(c, req.CanManageUsers, req.CanManageFiles, req.CanUpload, req.CanAdvancedUpload) {
return
}
password, err := randomToken(12)
@@ -688,7 +748,7 @@ func (a *App) createUser(c *gin.Context) {
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
user := User{Username: req.Username, PasswordHash: string(hash), CanManageUsers: req.CanManageUsers, CanUpload: req.CanUpload, CanAdvancedUpload: req.CanAdvancedUpload, MustChangePassword: true}
user := User{Username: req.Username, PasswordHash: string(hash), CanManageUsers: req.CanManageUsers, CanManageFiles: req.CanManageFiles, CanUpload: req.CanUpload, CanAdvancedUpload: req.CanAdvancedUpload, MustChangePassword: true}
if err := a.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
@@ -708,6 +768,7 @@ func (a *App) updateUser(c *gin.Context) {
var req struct {
Username string `json:"username"`
CanManageUsers bool `json:"canManageUsers"`
CanManageFiles bool `json:"canManageFiles"`
CanUpload bool `json:"canUpload"`
CanAdvancedUpload bool `json:"canAdvancedUpload"`
}
@@ -720,10 +781,10 @@ func (a *App) updateUser(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
if !requireManageableTarget(c, &user) || !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) {
if !requireManageableTarget(c, &user) || !requireAssignablePermissions(c, req.CanManageUsers, req.CanManageFiles, req.CanUpload, req.CanAdvancedUpload) {
return
}
err := a.db.Model(&user).Updates(map[string]any{"username": req.Username, "can_manage_users": req.CanManageUsers, "can_upload": req.CanUpload, "can_advanced_upload": req.CanAdvancedUpload}).Error
err := a.db.Model(&user).Updates(map[string]any{"username": req.Username, "can_manage_users": req.CanManageUsers, "can_manage_files": req.CanManageFiles, "can_upload": req.CanUpload, "can_advanced_upload": req.CanAdvancedUpload}).Error
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
return
+77 -1
View File
@@ -90,7 +90,7 @@ func TestBootstrapAdminAndForcePasswordChange(t *testing.T) {
if err := app.db.First(&admin, 1).Error; err != nil {
t.Fatal(err)
}
if admin.Username != "admin" || !admin.MustChangePassword || !admin.CanManageUsers || !admin.CanAdvancedUpload {
if admin.Username != "admin" || !admin.MustChangePassword || !admin.CanManageUsers || !admin.CanManageFiles || !admin.CanAdvancedUpload {
t.Fatalf("unexpected admin: %+v", admin)
}
if bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) != nil {
@@ -113,6 +113,25 @@ func TestBootstrapAdminAndForcePasswordChange(t *testing.T) {
}
}
func TestEnsureAdminRestoresAllPermissions(t *testing.T) {
app, _ := testApp(t)
if err := app.db.Model(&User{}).Where("id = ?", 1).Updates(map[string]any{
"can_manage_users": false, "can_manage_files": false, "can_upload": false, "can_advanced_upload": false,
}).Error; err != nil {
t.Fatal(err)
}
if password, err := app.ensureAdmin(); err != nil || password != "" {
t.Fatalf("ensure admin: password = %q, err = %v", password, err)
}
var admin User
if err := app.db.First(&admin, 1).Error; err != nil {
t.Fatal(err)
}
if !admin.CanManageUsers || !admin.CanManageFiles || !admin.CanUpload || !admin.CanAdvancedUpload {
t.Fatalf("admin permissions were not restored: %+v", admin)
}
}
func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) {
app, _ := testApp(t)
hash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost)
@@ -295,6 +314,58 @@ func TestCleanupRemovesExpiredFileFromDatabaseAndDisk(t *testing.T) {
}
}
func TestFileManagementRequiresPermissionAndDeletesFile(t *testing.T) {
app, _ := testApp(t)
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost)
owner := User{Username: "file-owner", PasswordHash: string(passwordHash), CanUpload: true}
manager := User{Username: "file-manager", PasswordHash: string(passwordHash), CanManageFiles: true}
viewer := User{Username: "no-file-access", PasswordHash: string(passwordHash), CanManageUsers: true}
for _, user := range []*User{&owner, &manager, &viewer} {
if err := app.db.Create(user).Error; err != nil {
t.Fatal(err)
}
}
expires := time.Now().Add(time.Hour)
max := 3
file := SharedFile{Hash: "managed1", OriginalName: "managed.txt", MimeType: "text/plain", Size: 7, OwnerID: owner.ID, ExpiresAt: &expires, MaxDownloads: &max, Ready: true}
if err := app.db.Create(&file).Error; err != nil {
t.Fatal(err)
}
path := filepath.Join(app.filesDir, file.Hash)
if err := os.WriteFile(path, []byte("managed"), 0o640); err != nil {
t.Fatal(err)
}
router := app.router()
viewerCookie := loginAs(t, router, viewer.Username, "password-123")
denied := request(t, router, http.MethodGet, "/api/files", nil, "", viewerCookie)
if denied.Code != http.StatusForbidden {
t.Fatalf("list without permission status = %d", denied.Code)
}
denied = request(t, router, http.MethodDelete, "/api/files/"+jsonNumber(file.ID), nil, "", viewerCookie)
if denied.Code != http.StatusForbidden {
t.Fatalf("delete without permission status = %d", denied.Code)
}
managerCookie := loginAs(t, router, manager.Username, "password-123")
listed := request(t, router, http.MethodGet, "/api/files", nil, "", managerCookie)
if listed.Code != http.StatusOK || !strings.Contains(listed.Body.String(), owner.Username) || !strings.Contains(listed.Body.String(), file.Hash) {
t.Fatalf("list files status = %d, body = %s", listed.Code, listed.Body.String())
}
deleted := request(t, router, http.MethodDelete, "/api/files/"+jsonNumber(file.ID), nil, "", managerCookie)
if deleted.Code != http.StatusNoContent {
t.Fatalf("delete file status = %d, body = %s", deleted.Code, deleted.Body.String())
}
var count int64
app.db.Model(&SharedFile{}).Where("id = ?", file.ID).Count(&count)
if count != 0 {
t.Fatal("deleted file record remains")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("deleted file remains on disk")
}
}
func TestBasicUploadLimitAndAdvancedInheritance(t *testing.T) {
app, _ := testApp(t)
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost)
@@ -380,6 +451,11 @@ func TestUserManagerCannotGrantOrTakeOverPermissionsTheyDoNotHave(t *testing.T)
if created.Code != http.StatusForbidden {
t.Fatalf("create advanced user status = %d, body = %s", created.Code, created.Body.String())
}
createBody, _ = json.Marshal(map[string]any{"username": "file-admin", "canManageFiles": true})
created = request(t, router, http.MethodPost, "/api/users", bytes.NewReader(createBody), "application/json", cookie)
if created.Code != http.StatusForbidden {
t.Fatalf("grant file management status = %d, body = %s", created.Code, created.Body.String())
}
updateBody, _ := json.Marshal(map[string]any{"username": basicUser.Username, "canUpload": true, "canAdvancedUpload": true})
updated := request(t, router, http.MethodPut, "/api/users/"+jsonNumber(basicUser.ID), bytes.NewReader(updateBody), "application/json", cookie)
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f7f2" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<title>ufile · 文件共享中心</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect x="16" y="16" width="224" height="224" rx="56" fill="#176b45"/>
<path d="M76 52h72l32 32v120H76z" fill="#fff"/>
<path d="M148 52v32h32z" fill="#bfe8d2"/>
<path d="M104 112v43a24 24 0 0 0 48 0v-43" fill="none" stroke="#176b45" stroke-linecap="round" stroke-width="16"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

+30 -8
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import {
ArrowDownToLine, Check, Copy, Eye, FileArchive, FileUp,
ArrowDownToLine, Check, Copy, Eye, FileArchive, Files, FileUp,
KeyRound, LoaderCircle, LogOut, ShieldCheck,
UploadCloud, Users, X,
Trash2, UploadCloud, Users, X,
} from 'lucide-react'
import { api, json } from './api'
import { useAppStore } from './store'
@@ -164,7 +164,7 @@ function UploadResult({ file, onAgain }) {
function UserModal({ value, onClose, onSaved }) {
const actor = useAppStore((s) => s.user)
const canGrantUpload = actor.canUpload || actor.canAdvancedUpload
const [form, setForm] = useState(value || { username: '', canManageUsers: false, canUpload: canGrantUpload, canAdvancedUpload: false })
const [form, setForm] = useState(value || { username: '', canManageUsers: false, canManageFiles: false, canUpload: canGrantUpload, canAdvancedUpload: false })
const [error, setError] = useState('')
const submit = async (e) => {
e.preventDefault(); setError('')
@@ -172,7 +172,7 @@ function UserModal({ value, onClose, onSaved }) {
catch (err) { setError(err.message) }
}
const checkbox = (key, title, hint, disabled = false) => <label className={`flex gap-3 rounded-xl border border-line p-3.5 ${disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer'}`}><input className="mt-1 size-4 accent-leaf" type="checkbox" disabled={disabled} checked={form[key]} onChange={(e) => setForm({ ...form, [key]: e.target.checked })} /><span><span className="block text-sm font-medium">{title}</span><span className="mt-0.5 block text-xs text-ink/45">{hint}</span></span></label>
return <div className="fixed inset-0 z-40 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><form onSubmit={submit} className="card w-full max-w-md p-6"><div className="flex items-center justify-between"><h2 className="text-lg font-semibold">{value ? '编辑用户' : '创建用户'}</h2><button type="button" onClick={onClose}><X size={20} /></button></div><label className="label mt-5">用户名</label><input className="field" required pattern="[A-Za-z0-9_.-]{3,32}" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /><p className="mt-4 text-xs text-ink/45">只能分配你当前拥有的权限。</p><div className="mt-2 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.canAdvancedUpload)}</div>{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<div className="mt-6 flex justify-end gap-2"><button type="button" className="btn-secondary" onClick={onClose}>取消</button><button className="btn-primary">保存</button></div></form></div>
return <div className="fixed inset-0 z-40 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><form onSubmit={submit} className="card max-h-[90vh] w-full max-w-md overflow-y-auto p-6"><div className="flex items-center justify-between"><h2 className="text-lg font-semibold">{value ? '编辑用户' : '创建用户'}</h2><button type="button" onClick={onClose}><X size={20} /></button></div><label className="label mt-5">用户名</label><input className="field" required pattern="[A-Za-z0-9_.-]{3,32}" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /><p className="mt-4 text-xs text-ink/45">只能分配你当前拥有的权限。</p><div className="mt-2 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canManageFiles', '文件管理', '查看并删除所有用户上传的文件', !actor.canManageFiles)}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.canAdvancedUpload)}</div>{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<div className="mt-6 flex justify-end gap-2"><button type="button" className="btn-secondary" onClick={onClose}>取消</button><button className="btn-primary">保存</button></div></form></div>
}
function UsersPanel({ notify }) {
@@ -184,15 +184,37 @@ function UsersPanel({ notify }) {
const [secret, setSecret] = useState(null)
useEffect(() => { loadUsers() }, [loadUsers])
const saved = async (result) => { setModalOpen(false); setEditing(undefined); await loadUsers(); if (result.temporaryPassword) setSecret({ title: '用户已创建', password: result.temporaryPassword }); else notify('用户信息已保存') }
const manageable = (user) => (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload)
const manageable = (user) => (!user.canManageFiles || actor.canManageFiles) && (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload)
const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; try { const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) } catch (error) { notify(error.message) } }
const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; try { await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') } catch (error) { notify(error.message) } }
return <section className="card overflow-hidden"><div className="flex items-center justify-between border-b border-line px-5 py-4"><div><h2 className="font-semibold">用户管理</h2><p className="mt-0.5 text-xs text-ink/40">只能管理权限不高于自己的用户</p></div><button className="btn-primary !py-2 text-sm" onClick={() => { setEditing(undefined); setModalOpen(true) }}>创建用户</button></div><div className="divide-y divide-line">{users.map((user) => <div key={user.id} className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center"><div className="grid size-10 shrink-0 place-items-center rounded-full bg-paper font-semibold text-leaf">{user.username.slice(0, 1).toUpperCase()}</div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><p className="font-medium">{user.username}</p><span className="text-xs text-ink/35">#{user.id}</span>{user.mustChangePassword && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700">待改密</span>}</div><p className="mt-1 text-xs text-ink/45">{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}</p></div>{manageable(user) ? <div className="flex gap-2"><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => { setEditing(user); setModalOpen(true) }}>编辑</button><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => reset(user)}>重置密码</button>{user.id !== 1 && <button className="rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50" onClick={() => remove(user)}>删除</button>}</div> : <span className="text-xs text-ink/35">权限高于你</span>}</div>)}</div>
return <section className="card overflow-hidden"><div className="flex items-center justify-between border-b border-line px-5 py-4"><div><h2 className="font-semibold">用户管理</h2><p className="mt-0.5 text-xs text-ink/40">只能管理权限不高于自己的用户</p></div><button className="btn-primary !py-2 text-sm" onClick={() => { setEditing(undefined); setModalOpen(true) }}>创建用户</button></div><div className="divide-y divide-line">{users.map((user) => <div key={user.id} className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center"><div className="grid size-10 shrink-0 place-items-center rounded-full bg-paper font-semibold text-leaf">{user.username.slice(0, 1).toUpperCase()}</div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><p className="font-medium">{user.username}</p><span className="text-xs text-ink/35">#{user.id}</span>{user.mustChangePassword && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700">待改密</span>}</div><p className="mt-1 text-xs text-ink/45">{[user.canManageUsers && '用户管理', user.canManageFiles && '文件管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}</p></div>{manageable(user) ? <div className="flex gap-2"><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => { setEditing(user); setModalOpen(true) }}>编辑</button><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => reset(user)}>重置密码</button>{user.id !== 1 && <button className="rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50" onClick={() => remove(user)}>删除</button>}</div> : <span className="text-xs text-ink/35">权限高于你</span>}</div>)}</div>
{modalOpen && <UserModal value={editing} onClose={() => setModalOpen(false)} onSaved={saved} />}
{secret && <div className="fixed inset-0 z-50 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><div className="card w-full max-w-md p-7"><h2 className="text-lg font-semibold">{secret.title}</h2><p className="mt-2 text-sm text-ink/50">该密码只显示这一次,用户登录后必须修改。</p><div className="mt-5 flex items-center justify-between rounded-xl bg-paper p-4 font-mono text-sm"><span className="break-all">{secret.password}</span><button onClick={() => navigator.clipboard.writeText(secret.password)}><Copy size={17} /></button></div><button className="btn-primary mt-5 w-full" onClick={() => setSecret(null)}>我已保存</button></div></div>}
</section>
}
function FileManagementPanel({ notify }) {
const files = useAppStore((s) => s.managedFiles)
const loadFiles = useAppStore((s) => s.loadManagedFiles)
const [loading, setLoading] = useState(true)
const refresh = async () => { setLoading(true); try { await loadFiles() } catch (error) { notify(error.message) } finally { setLoading(false) } }
useEffect(() => { refresh() }, [])
const remove = async (file) => {
if (!confirm(`删除文件「${file.name}」?此操作不可撤销。`)) return
try { await api(`/api/files/${file.id}`, { method: 'DELETE' }); await refresh(); notify('文件已删除') }
catch (error) { notify(error.message) }
}
const copy = async (file) => { await navigator.clipboard.writeText(`${location.origin}/uf/${file.hash}`); notify('文件链接已复制') }
return <section className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-line px-5 py-4"><div><h2 className="font-semibold">文件管理</h2><p className="mt-0.5 text-xs text-ink/40">全部用户当前有效的文件</p></div><button className="btn-secondary !py-2 text-sm" disabled={loading} onClick={refresh}>{loading ? <LoaderCircle className="animate-spin" size={15} /> : `${files.length} 个文件`}</button></div>
{loading && files.length === 0 ? <div className="grid place-items-center px-5 py-16"><LoaderCircle className="animate-spin text-leaf" /></div> : files.length === 0 ? <div className="px-5 py-16 text-center text-sm text-ink/40">当前没有有效文件</div> : <div className="divide-y divide-line">{files.map((file) => <div key={file.id} className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-1 items-start gap-3"><div className="grid size-10 shrink-0 place-items-center rounded-xl bg-paper text-leaf"><FileArchive size={18} /></div><div className="min-w-0"><p className="truncate font-medium" title={file.name}>{file.name}</p><button className="mt-1 font-mono text-xs text-ink/40 hover:text-leaf" onClick={() => copy(file)}>{file.hash} · {formatBytes(file.size)} · 点击复制链接</button></div></div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-ink/50 sm:grid-cols-4 lg:w-[520px]"><span><b className="block font-medium text-ink/70">上传者</b>{file.ownerUsername || `#${file.ownerId}`}</span><span><b className="block font-medium text-ink/70">失效时间</b>{formatDate(file.expiresAt)}</span><span><b className="block font-medium text-ink/70">访问次数</b>{file.maxDownloads == null ? '无限' : `${file.downloadCount} / ${file.maxDownloads}`}</span><span><b className="block font-medium text-ink/70">取件码</b>{file.hasPickupCode ? `已设置 · 失败 ${file.failedCodeAttempts}/5` : '未设置'}</span></div>
<button className="inline-flex items-center justify-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50" onClick={() => remove(file)}><Trash2 size={15} />删除</button>
</div>)}</div>}
</section>
}
function Dashboard() {
const user = useAppStore((s) => s.user)
const logout = useAppStore((s) => s.logout)
@@ -200,8 +222,8 @@ function Dashboard() {
const [toast, setToast] = useState('')
const notify = (message) => setToast(message)
return <div className="noise min-h-screen"><header className="sticky top-0 z-20 border-b border-line/80 bg-paper/90 backdrop-blur-xl"><div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-3.5"><div className="flex items-center gap-2.5"><div className="grid size-9 place-items-center rounded-xl bg-leaf text-white"><FileArchive size={19} /></div><span className="font-semibold">ufile</span></div><div className="flex items-center gap-3"><span className="hidden text-sm text-ink/50 sm:block">{user.username}</span><button className="btn-secondary !p-2.5" title="退出登录" onClick={logout}><LogOut size={16} /></button></div></div></header>
<main className="mx-auto max-w-6xl px-5 py-7"><div className="mb-7 flex gap-1 rounded-xl bg-ink/5 p-1 sm:w-fit"><button className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'files' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('files')}><FileUp size={16} />文件空间</button>{user.canManageUsers && <button className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'users' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('users')}><Users size={16} />用户管理</button>}</div>
{tab === 'files' ? <div className="mx-auto max-w-2xl"><UploadPanel /></div> : <UsersPanel notify={notify} />}
<main className="mx-auto max-w-6xl px-5 py-7"><div className="mb-7 flex w-fit max-w-full gap-1 overflow-x-auto rounded-xl bg-ink/5 p-1"><button className={`flex shrink-0 items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'files' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('files')}><FileUp size={16} />文件空间</button>{user.canManageFiles && <button className={`flex shrink-0 items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'managed-files' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('managed-files')}><Files size={16} />文件管理</button>}{user.canManageUsers && <button className={`flex shrink-0 items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'users' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('users')}><Users size={16} />用户管理</button>}</div>
{tab === 'files' ? <div className="mx-auto max-w-2xl"><UploadPanel /></div> : tab === 'managed-files' ? <FileManagementPanel notify={notify} /> : <UsersPanel notify={notify} />}
</main>{toast && <Toast message={toast} onClose={() => setToast('')} />}
</div>
}
+3 -1
View File
@@ -5,6 +5,7 @@ export const useAppStore = create((set) => ({
user: null,
checking: true,
users: [],
managedFiles: [],
async checkSession() {
try { set({ user: await api('/api/auth/me') }) }
catch { set({ user: null }) }
@@ -16,8 +17,9 @@ export const useAppStore = create((set) => ({
},
async logout() {
await api('/api/auth/logout', { method: 'POST' })
set({ user: null, users: [] })
set({ user: null, users: [], managedFiles: [] })
},
setUser: (user) => set({ user }),
async loadUsers() { set({ users: await api('/api/users') }) },
async loadManagedFiles() { set({ managedFiles: await api('/api/files') }) },
}))
+6 -1
View File
@@ -7,6 +7,7 @@ type User struct {
Username string `gorm:"uniqueIndex;size:32;not null" json:"username"`
PasswordHash string `json:"-"`
CanManageUsers bool `json:"canManageUsers"`
CanManageFiles bool `json:"canManageFiles"`
CanUpload bool `json:"canUpload"`
CanAdvancedUpload bool `json:"canAdvancedUpload"`
MustChangePassword bool `json:"mustChangePassword"`
@@ -49,7 +50,10 @@ type fileResponse struct {
ExpiresAt *time.Time `json:"expiresAt"`
MaxDownloads *int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"`
OwnerID uint `json:"ownerId"`
OwnerUsername string `json:"ownerUsername,omitempty"`
HasPickupCode bool `json:"hasPickupCode"`
FailedCodeAttempts int `json:"failedCodeAttempts"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -57,6 +61,7 @@ func presentFile(f SharedFile) fileResponse {
return fileResponse{
ID: f.ID, Hash: f.Hash, Name: f.OriginalName, MimeType: f.MimeType,
Size: f.Size, ExpiresAt: f.ExpiresAt, MaxDownloads: f.MaxDownloads,
DownloadCount: f.DownloadCount, HasPickupCode: f.HasPickupCode(), CreatedAt: f.CreatedAt,
DownloadCount: f.DownloadCount, OwnerID: f.OwnerID, HasPickupCode: f.HasPickupCode(),
FailedCodeAttempts: f.FailedCodeAttempts, CreatedAt: f.CreatedAt,
}
}