初始化
This commit is contained in:
265
internal/handler/file.go
Normal file
265
internal/handler/file.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// adminUserID 是 code_count 体系的超级管理员账号(users.id=1),可见/可管全部文件。
|
||||
const adminUserID = 1
|
||||
|
||||
// FileHandler 提供图片上传与素材库管理:内容落盘 storage_dir,元数据入 pms_files 表。
|
||||
// 身份沿用 code-count 的内网信任模型:客户端自报 userId/teamId,服务端据
|
||||
// code_count 库的 team_members / users 表判定管理范围(防误操作,不防伪造)。
|
||||
type FileHandler struct {
|
||||
DB *gorm.DB
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// extByMime 是允许上传的图片类型白名单(按内容嗅探判定,不信任扩展名)。
|
||||
var extByMime = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
// Upload 处理 POST /api/v1/files:multipart 字段 file 必填,kind(avatar|content)、
|
||||
// userId、teamId 可选。同一归属(userId+teamId)重复上传同内容直接复用已有记录(秒传);
|
||||
// 不同归属各自落盘,保证"删除自己的素材"不影响他人。
|
||||
// 成功返回 {id, name, url, size, mime},url 可直接放进 <img src> / Markdown。
|
||||
func (h *FileHandler) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
if fh.Size > h.Cfg.MaxUploadBytes() {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, h.Cfg.MaxUploadBytes()+1))
|
||||
if err != nil || int64(len(data)) > h.Cfg.MaxUploadBytes() {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
|
||||
return
|
||||
}
|
||||
mime := http.DetectContentType(data)
|
||||
ext, ok := extByMime[mime]
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "UNSUPPORTED_TYPE"})
|
||||
return
|
||||
}
|
||||
uid := parseID(c.PostForm("userId"))
|
||||
tid := parseID(c.PostForm("teamId"))
|
||||
sum := hex.EncodeToString(func() []byte { s := sha256.Sum256(data); return s[:] }())
|
||||
var rec model.File
|
||||
if h.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, uid, tid).First(&rec).Error == nil {
|
||||
c.JSON(http.StatusOK, h.fileResponse(c, rec))
|
||||
return
|
||||
}
|
||||
name := storedName(ext)
|
||||
full := filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(full, data, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
|
||||
return
|
||||
}
|
||||
rec = model.File{
|
||||
Name: name,
|
||||
Original: clip(filepath.Base(fh.Filename), 255),
|
||||
Mime: mime,
|
||||
Size: int64(len(data)),
|
||||
SHA256: sum,
|
||||
UserID: uid,
|
||||
TeamID: tid,
|
||||
Kind: normalizeKind(c.PostForm("kind")),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := h.DB.Create(&rec).Error; err != nil {
|
||||
os.Remove(full)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.fileResponse(c, rec))
|
||||
}
|
||||
|
||||
// fileItem 是素材库列表项(pms_files 联 users 取上传者名)。
|
||||
type fileItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Original string `json:"original"`
|
||||
Mime string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
UserID int64 `json:"userId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
Kind string `json:"kind"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Username string `json:"username"`
|
||||
URL string `json:"url" gorm:"-"`
|
||||
}
|
||||
|
||||
// List 处理 GET /api/v1/files:素材库分页列表。
|
||||
// scope=mine 看自己(任何登录用户);scope=team 看指定团队(需为该团队 owner/admin);
|
||||
// scope=all 看全部(仅 userId=1)。按 id 倒序分页。
|
||||
func (h *FileHandler) List(c *gin.Context) {
|
||||
uid := parseID(c.Query("userId"))
|
||||
if uid <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "USER_REQUIRED"})
|
||||
return
|
||||
}
|
||||
var where func(*gorm.DB) *gorm.DB
|
||||
switch c.Query("scope") {
|
||||
case "mine":
|
||||
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", uid) }
|
||||
case "team":
|
||||
tid := parseID(c.Query("teamId"))
|
||||
if !h.isTeamAdmin(tid, uid) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", tid) }
|
||||
case "all":
|
||||
if uid != adminUserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
where = func(db *gorm.DB) *gorm.DB { return db }
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_SCOPE"})
|
||||
return
|
||||
}
|
||||
page := parseID(c.Query("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := parseID(c.Query("pageSize"))
|
||||
if size < 1 || size > 100 {
|
||||
size = 24
|
||||
}
|
||||
var total int64
|
||||
if err := h.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
|
||||
return
|
||||
}
|
||||
items := []fileItem{}
|
||||
err := h.DB.Table("pms_files").Scopes(where).
|
||||
Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username").
|
||||
Joins("LEFT JOIN users u ON u.id = pms_files.user_id").
|
||||
Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)).
|
||||
Scan(&items).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
items[i].URL = h.publicURL(c, items[i].Name)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "items": items})
|
||||
}
|
||||
|
||||
// Delete 处理 DELETE /api/v1/files/:id:删除记录与磁盘文件。
|
||||
// 允许:本人、超管(userId=1)、该文件归属团队的 owner/admin。
|
||||
func (h *FileHandler) Delete(c *gin.Context) {
|
||||
uid := parseID(c.Query("userId"))
|
||||
id := parseID(c.Param("id"))
|
||||
var rec model.File
|
||||
if h.DB.First(&rec, id).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
allowed := uid == adminUserID ||
|
||||
(uid > 0 && rec.UserID == uid) ||
|
||||
(rec.TeamID > 0 && h.isTeamAdmin(rec.TeamID, uid))
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
if err := h.DB.Delete(&model.File{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "DELETE_FAILED"})
|
||||
return
|
||||
}
|
||||
// 每条记录独占一个文件(name 唯一),删记录即可删盘;失败不影响结果(孤儿文件可人工清理)。
|
||||
_ = os.Remove(filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(rec.Name)))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// isTeamAdmin 查 code_count 库的 team_members:该用户是否为团队 owner/admin。
|
||||
func (h *FileHandler) isTeamAdmin(teamID, userID int64) bool {
|
||||
if teamID <= 0 || userID <= 0 {
|
||||
return false
|
||||
}
|
||||
var n int64
|
||||
h.DB.Table("team_members").
|
||||
Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID).
|
||||
Count(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (h *FileHandler) fileResponse(c *gin.Context, f model.File) gin.H {
|
||||
return gin.H{"id": f.ID, "name": f.Name, "url": h.publicURL(c, f.Name), "size": f.Size, "mime": f.Mime, "teamId": f.TeamID}
|
||||
}
|
||||
|
||||
// publicURL 拼接文件公开访问地址:优先配置的 base_url,否则按本次请求推断。
|
||||
func (h *FileHandler) publicURL(c *gin.Context, name string) string {
|
||||
base := h.Cfg.BaseURL
|
||||
if base == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + c.Request.Host
|
||||
}
|
||||
return base + "/files/" + name
|
||||
}
|
||||
|
||||
// storedName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex),
|
||||
// 文件公开可读但路径不可猜测,等效 capability URL。
|
||||
func storedName(ext string) string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext
|
||||
}
|
||||
|
||||
func normalizeKind(k string) string {
|
||||
if k == "avatar" || k == "content" {
|
||||
return k
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseID(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
262
internal/handler/file_test.go
Normal file
262
internal/handler/file_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
"nl-pms-api/internal/router"
|
||||
)
|
||||
|
||||
const testKey = "test-key"
|
||||
|
||||
// newTestServer 起一个内存 SQLite 后端的完整路由,并铺好 users / team_members 假数据:
|
||||
// 1=admin(超管)、2=alice(团队 10 owner)、3=bob(团队 10 member)。
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *gorm.DB, *config.Config) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.File{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
db.Exec(`CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT)`)
|
||||
db.Exec(`CREATE TABLE team_members(team_id INTEGER, user_id INTEGER, role TEXT)`)
|
||||
db.Exec(`INSERT INTO users(id, username) VALUES(1,'admin'),(2,'alice'),(3,'bob')`)
|
||||
db.Exec(`INSERT INTO team_members(team_id,user_id,role) VALUES(10,2,'owner'),(10,3,'member')`)
|
||||
cfg := &config.Config{Env: "dev", APIKey: testKey, StorageDir: t.TempDir(), MaxUploadMB: 20}
|
||||
srv := httptest.NewServer(router.New(cfg, db))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, db, cfg
|
||||
}
|
||||
|
||||
// pngBytes 生成一张纯色 PNG;tone 不同则内容(sha256)不同。
|
||||
func pngBytes(t *testing.T, tone uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
for x := 0; x < 2; x++ {
|
||||
for y := 0; y < 2; y++ {
|
||||
img.Set(x, y, color.RGBA{R: tone, G: 90, B: 60, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
type uploadResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
|
||||
func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (*http.Response, uploadResp) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
fw, err := w.CreateFormFile("file", "test.png")
|
||||
if err != nil {
|
||||
t.Fatalf("form file: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_ = w.WriteField("kind", "content")
|
||||
_ = w.WriteField("userId", userID)
|
||||
_ = w.WriteField("teamId", teamID)
|
||||
_ = w.Close()
|
||||
req, _ := http.NewRequest("POST", url+"/api/v1/files", &body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
var out uploadResp
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
resp.Body.Close()
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func listFiles(t *testing.T, base, scope, userID, teamID string) (int, int64, []map[string]any) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", base+"/api/v1/files?scope="+scope+"&userId="+userID+"&teamId="+teamID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
Total int64 `json:"total"`
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out.Total, out.Items
|
||||
}
|
||||
|
||||
func deleteFile(t *testing.T, base string, id int64, userID string) int {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id)+"?userId="+userID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func jsonNum(n int64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("healthz: %v %v", err, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRequiresAuth(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, "", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := upload(t, srv.URL, "wrong", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadServeAndScopedDedupe(t *testing.T) {
|
||||
srv, db, cfg := newTestServer(t)
|
||||
data := pngBytes(t, 200)
|
||||
resp, out := upload(t, srv.URL, testKey, data, "2", "10")
|
||||
if resp.StatusCode != 200 || out.Name == "" || out.Mime != "image/png" {
|
||||
t.Fatalf("upload failed: %d %+v", resp.StatusCode, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(out.Name))); err != nil {
|
||||
t.Fatalf("file not on disk: %v", err)
|
||||
}
|
||||
// 公开访问返回原始内容
|
||||
got, err := http.Get(srv.URL + "/files/" + out.Name)
|
||||
if err != nil || got.StatusCode != 200 {
|
||||
t.Fatalf("serve: %v %v", err, got)
|
||||
}
|
||||
var served bytes.Buffer
|
||||
_, _ = served.ReadFrom(got.Body)
|
||||
if !bytes.Equal(served.Bytes(), data) {
|
||||
t.Fatal("served content mismatch")
|
||||
}
|
||||
// 同归属(user=2, team=10)重复上传 → 秒传复用同一条记录
|
||||
_, again := upload(t, srv.URL, testKey, data, "2", "10")
|
||||
if again.ID != out.ID || again.Name != out.Name {
|
||||
t.Fatalf("same-owner dedupe failed: %+v vs %+v", again, out)
|
||||
}
|
||||
// 不同归属(user=3)上传同内容 → 独立记录独立文件,删除互不影响
|
||||
_, other := upload(t, srv.URL, testKey, data, "3", "10")
|
||||
if other.ID == out.ID || other.Name == out.Name {
|
||||
t.Fatalf("cross-owner upload should create its own record: %+v", other)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.File{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsNonImage(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, testKey, []byte("plain text, not an image"), "2", "0"); resp.StatusCode != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("want 415, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListScopes(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10") // alice 团队图 x2
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 20), "2", "10")
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "10") // bob 团队图
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 40), "3", "0") // bob 个人图
|
||||
|
||||
if code, total, items := listFiles(t, srv.URL, "mine", "2", "0"); code != 200 || total != 2 || len(items) != 2 {
|
||||
t.Fatalf("mine(alice): code=%d total=%d n=%d", code, total, len(items))
|
||||
}
|
||||
// 团队视角:owner 可见团队 3 张(含上传者用户名)
|
||||
code, total, items := listFiles(t, srv.URL, "team", "2", "10")
|
||||
if code != 200 || total != 3 {
|
||||
t.Fatalf("team(owner): code=%d total=%d", code, total)
|
||||
}
|
||||
if items[0]["username"] == "" {
|
||||
t.Fatalf("team items should carry username: %+v", items[0])
|
||||
}
|
||||
// member 无权看团队素材
|
||||
if code, _, _ := listFiles(t, srv.URL, "team", "3", "10"); code != http.StatusForbidden {
|
||||
t.Fatalf("team(member) should be 403, got %d", code)
|
||||
}
|
||||
// all 仅超管
|
||||
if code, total, _ := listFiles(t, srv.URL, "all", "1", "0"); code != 200 || total != 4 {
|
||||
t.Fatalf("all(admin): code=%d total=%d", code, total)
|
||||
}
|
||||
if code, _, _ := listFiles(t, srv.URL, "all", "2", "0"); code != http.StatusForbidden {
|
||||
t.Fatalf("all(non-admin) should be 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePermissions(t *testing.T) {
|
||||
srv, _, cfg := newTestServer(t)
|
||||
_, aliceTeam := upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10")
|
||||
_, bobTeam := upload(t, srv.URL, testKey, pngBytes(t, 20), "3", "10")
|
||||
_, bobOwn := upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "0")
|
||||
_, aliceOwn := upload(t, srv.URL, testKey, pngBytes(t, 40), "2", "0")
|
||||
|
||||
// member 不能删别人的(即使同团队)
|
||||
if code := deleteFile(t, srv.URL, aliceTeam.ID, "3"); code != http.StatusForbidden {
|
||||
t.Fatalf("member deleting other's file should be 403, got %d", code)
|
||||
}
|
||||
// 团队 owner 可删团队内他人上传的
|
||||
if code := deleteFile(t, srv.URL, bobTeam.ID, "2"); code != 200 {
|
||||
t.Fatalf("owner deleting team file should be 200, got %d", code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(bobTeam.Name))); !os.IsNotExist(err) {
|
||||
t.Fatal("deleted file should be removed from disk")
|
||||
}
|
||||
// owner 不能删团队外的个人文件
|
||||
if code := deleteFile(t, srv.URL, bobOwn.ID, "2"); code != http.StatusForbidden {
|
||||
t.Fatalf("owner deleting personal file outside team should be 403, got %d", code)
|
||||
}
|
||||
// 本人可删自己的
|
||||
if code := deleteFile(t, srv.URL, bobOwn.ID, "3"); code != 200 {
|
||||
t.Fatalf("self delete should be 200, got %d", code)
|
||||
}
|
||||
// 超管可删任何
|
||||
if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != 200 {
|
||||
t.Fatalf("admin delete should be 200, got %d", code)
|
||||
}
|
||||
// 已删除 → 404
|
||||
if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != http.StatusNotFound {
|
||||
t.Fatalf("double delete should be 404, got %d", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user