diff --git a/README.md b/README.md index ac98167..25e24dc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ufile -一个单二进制运行的轻量文件快传服务。支持多用户和三级权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。 +一个单二进制运行的轻量文件快传服务。支持多用户权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。文件管理员可以查看并删除全部当前有效文件。 ## 构建 diff --git a/app.go b/app.go index 9647c91..0682256 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/app_test.go b/app_test.go index fd88526..cd54b0d 100644 --- a/app_test.go +++ b/app_test.go @@ -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) diff --git a/frontend/index.html b/frontend/index.html index da07e2b..079779e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ +
只能管理权限不高于自己的用户
{user.username}
#{user.id}{user.mustChangePassword && 待改密}{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}
只能管理权限不高于自己的用户
{user.username}
#{user.id}{user.mustChangePassword && 待改密}{[user.canManageUsers && '用户管理', user.canManageFiles && '文件管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}
该密码只显示这一次,用户登录后必须修改。
全部用户当前有效的文件
{file.name}