Merge pull request #1268 from trheyi/main
Enhance attachment management with public and share fields
This commit is contained in:
commit
6f44168b18
9 changed files with 1049 additions and 232 deletions
61
attachment/convert.go
Normal file
61
attachment/convert.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// toBool converts various types to boolean
|
||||
func toBool(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case int:
|
||||
return val != 0
|
||||
case int64:
|
||||
return val != 0
|
||||
case uint8: // MySQL tinyint(1)
|
||||
return val != 0
|
||||
case float64:
|
||||
return val != 0
|
||||
case string:
|
||||
normalized := strings.ToLower(strings.TrimSpace(val))
|
||||
switch normalized {
|
||||
case "true", "1", "enabled", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// toString converts various types to string
|
||||
func toString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", val)
|
||||
case bool:
|
||||
if val {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
|
|
@ -652,9 +652,9 @@ func (manager Manager) List(ctx context.Context, option ListOption) (*ListResult
|
|||
|
||||
// Add select fields
|
||||
if len(option.Select) > 0 {
|
||||
queryParam.Select = make([]interface{}, len(option.Select))
|
||||
for i, field := range option.Select {
|
||||
queryParam.Select[i] = field
|
||||
queryParam.Select = make([]interface{}, 0, len(option.Select))
|
||||
for _, field := range option.Select {
|
||||
queryParam.Select = append(queryParam.Select, field)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -680,6 +680,14 @@ func (manager Manager) List(ctx context.Context, option ListOption) (*ListResult
|
|||
}
|
||||
}
|
||||
|
||||
// Add advanced where clauses (for permission filtering, etc.)
|
||||
if len(option.Wheres) > 0 {
|
||||
if queryParam.Wheres == nil {
|
||||
queryParam.Wheres = make([]model.QueryWhere, 0, len(option.Wheres))
|
||||
}
|
||||
queryParam.Wheres = append(queryParam.Wheres, option.Wheres...)
|
||||
}
|
||||
|
||||
// Add ordering
|
||||
if option.OrderBy != "" {
|
||||
// Parse order by string like "created_at desc" or "name asc"
|
||||
|
|
@ -1080,6 +1088,12 @@ func (manager Manager) saveFileToDatabase(ctx context.Context, file *File, stora
|
|||
|
||||
m := model.Select("__yao.attachment")
|
||||
|
||||
// Set default value for share if empty
|
||||
share := option.Share
|
||||
if share == "" {
|
||||
share = "private"
|
||||
}
|
||||
|
||||
// Prepare data for database
|
||||
data := map[string]interface{}{
|
||||
"file_id": file.ID,
|
||||
|
|
@ -1092,8 +1106,22 @@ func (manager Manager) saveFileToDatabase(ctx context.Context, file *File, stora
|
|||
"status": file.Status,
|
||||
"gzip": option.Gzip,
|
||||
"groups": option.Groups,
|
||||
"client_id": option.ClientID,
|
||||
"openid": option.OpenID,
|
||||
"public": option.Public,
|
||||
"share": share,
|
||||
}
|
||||
|
||||
// Add Yao permission fields if provided
|
||||
if option.YaoCreatedBy != "" {
|
||||
data["__yao_created_by"] = option.YaoCreatedBy
|
||||
}
|
||||
if option.YaoUpdatedBy != "" {
|
||||
data["__yao_updated_by"] = option.YaoUpdatedBy
|
||||
}
|
||||
if option.YaoTeamID != "" {
|
||||
data["__yao_team_id"] = option.YaoTeamID
|
||||
}
|
||||
if option.YaoTenantID != "" {
|
||||
data["__yao_tenant_id"] = option.YaoTenantID
|
||||
}
|
||||
|
||||
// Check if record exists first
|
||||
|
|
@ -1128,9 +1156,14 @@ func (manager Manager) getFileFromDatabase(ctx context.Context, fileID string) (
|
|||
m := model.Select("__yao.attachment")
|
||||
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{
|
||||
"file_id", "name", "content_type", "status", "user_path", "path", "bytes",
|
||||
"public", "share", "__yao_created_by", "__yao_team_id", "__yao_tenant_id",
|
||||
},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "file_id", Value: fileID},
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -1165,6 +1198,13 @@ func (manager Manager) getFileFromDatabase(ctx context.Context, fileID string) (
|
|||
file.Bytes = int(bytes)
|
||||
}
|
||||
|
||||
// Handle permission fields with safe conversion
|
||||
file.Public = toBool(record["public"])
|
||||
file.Share = toString(record["share"])
|
||||
file.YaoCreatedBy = toString(record["__yao_created_by"])
|
||||
file.YaoTeamID = toString(record["__yao_team_id"])
|
||||
file.YaoTenantID = toString(record["__yao_tenant_id"])
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -556,8 +556,8 @@ func TestInfo(t *testing.T) {
|
|||
option := UploadOption{
|
||||
Groups: []string{"info", "test"},
|
||||
OriginalFilename: "original-info-test.txt",
|
||||
ClientID: "test-client-123",
|
||||
OpenID: "test-openid-456",
|
||||
Public: false,
|
||||
Share: "private",
|
||||
Gzip: false,
|
||||
}
|
||||
|
||||
|
|
@ -1013,6 +1013,373 @@ func TestManagerLocalPath_NonExistentFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublicAndShareFields(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Force re-migrate the attachment table to ensure schema is up to date
|
||||
m := model.Select("__yao.attachment")
|
||||
if m != nil {
|
||||
// Drop and recreate table to get latest schema
|
||||
err := m.DropTable()
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to drop table: %v", err)
|
||||
}
|
||||
err = m.Migrate(false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to migrate table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
manager, err := RegisterDefault("test-public-share")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register manager: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: Upload with public=true and share=team
|
||||
t.Run("PublicTeamShare", func(t *testing.T) {
|
||||
content := "Public team shared file"
|
||||
reader := strings.NewReader(content)
|
||||
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "public-team.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
option := UploadOption{
|
||||
Groups: []string{"test"},
|
||||
OriginalFilename: "public-team.txt",
|
||||
Public: true,
|
||||
Share: "team",
|
||||
}
|
||||
|
||||
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload public team file: %v", err)
|
||||
}
|
||||
|
||||
// Verify in database
|
||||
m := model.Select("__yao.attachment")
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "file_id", Value: file.ID},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query database: %v", err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
t.Fatal("No record found in database")
|
||||
}
|
||||
|
||||
// Debug: print all fields
|
||||
t.Logf("Record fields: %+v", records[0])
|
||||
|
||||
publicValue := toBool(records[0]["public"])
|
||||
if !publicValue {
|
||||
t.Errorf("Expected public to be true, got: %v (type: %T)", records[0]["public"], records[0]["public"])
|
||||
}
|
||||
|
||||
shareValue := toString(records[0]["share"])
|
||||
if shareValue != "team" {
|
||||
t.Errorf("Expected share to be 'team', got: %v (type: %T)", records[0]["share"], records[0]["share"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Upload with public=false and share=private (default)
|
||||
t.Run("PrivateShare", func(t *testing.T) {
|
||||
content := "Private file"
|
||||
reader := strings.NewReader(content)
|
||||
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "private.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
option := UploadOption{
|
||||
Groups: []string{"test"},
|
||||
OriginalFilename: "private.txt",
|
||||
Public: false,
|
||||
Share: "private",
|
||||
}
|
||||
|
||||
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload private file: %v", err)
|
||||
}
|
||||
|
||||
// Verify in database
|
||||
m := model.Select("__yao.attachment")
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"public", "share"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "file_id", Value: file.ID},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query database: %v", err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
t.Fatal("No record found in database")
|
||||
}
|
||||
|
||||
publicValue := toBool(records[0]["public"])
|
||||
if publicValue {
|
||||
t.Errorf("Expected public to be false, got: %v", records[0]["public"])
|
||||
}
|
||||
|
||||
shareValue := toString(records[0]["share"])
|
||||
if shareValue != "private" {
|
||||
t.Errorf("Expected share to be 'private', got: %v", records[0]["share"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Upload without specifying share (should default to private)
|
||||
t.Run("DefaultSharePrivate", func(t *testing.T) {
|
||||
content := "Default share file"
|
||||
reader := strings.NewReader(content)
|
||||
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "default-share.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
option := UploadOption{
|
||||
Groups: []string{"test"},
|
||||
OriginalFilename: "default-share.txt",
|
||||
Public: false,
|
||||
// Share not specified, should default to "private"
|
||||
}
|
||||
|
||||
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload file with default share: %v", err)
|
||||
}
|
||||
|
||||
// Verify in database
|
||||
m := model.Select("__yao.attachment")
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"share"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "file_id", Value: file.ID},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query database: %v", err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
t.Fatal("No record found in database")
|
||||
}
|
||||
|
||||
shareValue := toString(records[0]["share"])
|
||||
if shareValue != "private" {
|
||||
t.Errorf("Expected default share to be 'private', got: %v", records[0]["share"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestYaoPermissionFields(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Force re-migrate the attachment table to ensure schema is up to date
|
||||
m := model.Select("__yao.attachment")
|
||||
if m != nil {
|
||||
// Drop and recreate table to get latest schema
|
||||
err := m.DropTable()
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to drop table: %v", err)
|
||||
}
|
||||
err = m.Migrate(false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to migrate table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
manager, err := RegisterDefault("test-yao-permission")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register manager: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: Upload with all Yao permission fields
|
||||
t.Run("AllYaoFields", func(t *testing.T) {
|
||||
content := "File with all Yao permission fields"
|
||||
reader := strings.NewReader(content)
|
||||
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "yao-all-fields.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
option := UploadOption{
|
||||
Groups: []string{"test"},
|
||||
OriginalFilename: "yao-all-fields.txt",
|
||||
YaoCreatedBy: "user123",
|
||||
YaoUpdatedBy: "user123",
|
||||
YaoTeamID: "team456",
|
||||
YaoTenantID: "tenant789",
|
||||
}
|
||||
|
||||
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload file with Yao fields: %v", err)
|
||||
}
|
||||
|
||||
// Verify in database
|
||||
m := model.Select("__yao.attachment")
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"__yao_created_by", "__yao_updated_by", "__yao_team_id", "__yao_tenant_id"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "file_id", Value: file.ID},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query database: %v", err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
t.Fatal("No record found in database")
|
||||
}
|
||||
|
||||
// Verify __yao_created_by
|
||||
createdBy := toString(records[0]["__yao_created_by"])
|
||||
if createdBy != "user123" {
|
||||
t.Errorf("Expected __yao_created_by to be 'user123', got: %v", records[0]["__yao_created_by"])
|
||||
}
|
||||
|
||||
// Verify __yao_updated_by
|
||||
updatedBy := toString(records[0]["__yao_updated_by"])
|
||||
if updatedBy != "user123" {
|
||||
t.Errorf("Expected __yao_updated_by to be 'user123', got: %v", records[0]["__yao_updated_by"])
|
||||
}
|
||||
|
||||
// Verify __yao_team_id
|
||||
teamID := toString(records[0]["__yao_team_id"])
|
||||
if teamID != "team456" {
|
||||
t.Errorf("Expected __yao_team_id to be 'team456', got: %v", records[0]["__yao_team_id"])
|
||||
}
|
||||
|
||||
// Verify __yao_tenant_id
|
||||
tenantID := toString(records[0]["__yao_tenant_id"])
|
||||
if tenantID != "tenant789" {
|
||||
t.Errorf("Expected __yao_tenant_id to be 'tenant789', got: %v", records[0]["__yao_tenant_id"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Upload with partial Yao fields (only team and tenant)
|
||||
t.Run("PartialYaoFields", func(t *testing.T) {
|
||||
content := "File with partial Yao fields"
|
||||
reader := strings.NewReader(content)
|
||||
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "yao-partial-fields.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
option := UploadOption{
|
||||
Groups: []string{"test"},
|
||||
OriginalFilename: "yao-partial-fields.txt",
|
||||
YaoTeamID: "team999",
|
||||
YaoTenantID: "tenant888",
|
||||
// YaoCreatedBy and YaoUpdatedBy not specified
|
||||
}
|
||||
|
||||
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload file with partial Yao fields: %v", err)
|
||||
}
|
||||
|
||||
// Verify in database
|
||||
m := model.Select("__yao.attachment")
|
||||
records, err := m.Get(model.QueryParam{
|
||||
Select: []interface{}{"__yao_team_id", "__yao_tenant_id"},
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "file_id", Value: file.ID},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query database: %v", err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
t.Fatal("No record found in database")
|
||||
}
|
||||
|
||||
// Verify __yao_team_id
|
||||
teamID := toString(records[0]["__yao_team_id"])
|
||||
if teamID != "team999" {
|
||||
t.Errorf("Expected __yao_team_id to be 'team999', got: %v", records[0]["__yao_team_id"])
|
||||
}
|
||||
|
||||
// Verify __yao_tenant_id
|
||||
tenantID := toString(records[0]["__yao_tenant_id"])
|
||||
if tenantID != "tenant888" {
|
||||
t.Errorf("Expected __yao_tenant_id to be 'tenant888', got: %v", records[0]["__yao_tenant_id"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Upload without Yao fields (should be null/empty in database)
|
||||
t.Run("NoYaoFields", func(t *testing.T) {
|
||||
content := "File without Yao fields"
|
||||
reader := strings.NewReader(content)
|
||||
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "yao-no-fields.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
option := UploadOption{
|
||||
Groups: []string{"test"},
|
||||
OriginalFilename: "yao-no-fields.txt",
|
||||
// No Yao fields specified
|
||||
}
|
||||
|
||||
file, err := manager.Upload(context.Background(), fileHeader, reader, option)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload file without Yao fields: %v", err)
|
||||
}
|
||||
|
||||
// Should succeed without errors
|
||||
if file.ID == "" {
|
||||
t.Error("File ID should not be empty")
|
||||
}
|
||||
|
||||
t.Logf("Successfully uploaded file without Yao fields - ID: %s", file.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManagerLocalPath_ValidationFlow(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"io"
|
||||
"mime/multipart"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/gou/types"
|
||||
)
|
||||
|
||||
|
|
@ -58,6 +59,13 @@ type File struct {
|
|||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Status string `json:"status"` // uploading, uploaded, indexing, indexed, upload_failed, index_failed
|
||||
|
||||
// Permission fields
|
||||
Public bool `json:"public,omitempty"` // Whether this attachment is shared across all teams
|
||||
Share string `json:"share,omitempty"` // Attachment sharing scope: "private" or "team"
|
||||
YaoCreatedBy string `json:"-"` // User who created the attachment (not exposed in JSON)
|
||||
YaoTeamID string `json:"-"` // Team ID for team-based access control (not exposed in JSON)
|
||||
YaoTenantID string `json:"-"` // Tenant ID for multi-tenancy support (not exposed in JSON)
|
||||
}
|
||||
|
||||
// FileResponse represents a file download response
|
||||
|
|
@ -80,9 +88,15 @@ type Attachment struct {
|
|||
UserPath string `json:"user_path,omitempty"` // User-specified complete file path
|
||||
Path string `json:"path,omitempty"` // Actual storage path
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false
|
||||
ClientID string `json:"client_id,omitempty"` // Client identifier
|
||||
OpenID string `json:"openid,omitempty"` // OpenID identifier
|
||||
Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false
|
||||
Public bool `json:"public,omitempty"` // Whether this attachment is shared across all teams in the platform
|
||||
Share string `json:"share,omitempty"` // Attachment sharing scope: "private" or "team"
|
||||
|
||||
// Yao custom fields for permission control
|
||||
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // User who created the attachment
|
||||
YaoUpdatedBy string `json:"__yao_updated_by,omitempty"` // User who last updated the attachment
|
||||
YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for team-based access control
|
||||
YaoTenantID string `json:"__yao_tenant_id,omitempty"` // Tenant ID for multi-tenancy support
|
||||
}
|
||||
|
||||
// Manager the manager struct
|
||||
|
|
@ -132,8 +146,14 @@ type UploadOption struct {
|
|||
Gzip bool `json:"gzip,omitempty" form:"gzip"` // Gzip the file, Optional, default is false
|
||||
OriginalFilename string `json:"original_filename,omitempty" form:"original_filename"` // Original filename sent separately to avoid encoding issues
|
||||
Groups []string `json:"groups,omitempty" form:"groups"` // Groups, Optional, default is empty, Multi-level groups like ["user", "user123", "chat", "chat456"]
|
||||
ClientID string `json:"client_id,omitempty" form:"client_id"` // Client identifier
|
||||
OpenID string `json:"openid,omitempty" form:"openid"` // OpenID identifier
|
||||
Public bool `json:"public,omitempty" form:"public"` // Whether this attachment is shared across all teams in the platform
|
||||
Share string `json:"share,omitempty" form:"share"` // Attachment sharing scope: "private" or "team"
|
||||
|
||||
// Yao custom fields for permission control
|
||||
YaoCreatedBy string `json:"__yao_created_by,omitempty" form:"__yao_created_by"` // User who created the attachment
|
||||
YaoUpdatedBy string `json:"__yao_updated_by,omitempty" form:"__yao_updated_by"` // User who last updated the attachment
|
||||
YaoTeamID string `json:"__yao_team_id,omitempty" form:"__yao_team_id"` // Team ID for team-based access control
|
||||
YaoTenantID string `json:"__yao_tenant_id,omitempty" form:"__yao_tenant_id"` // Tenant ID for multi-tenancy support
|
||||
}
|
||||
|
||||
// ListOption defines options for listing files
|
||||
|
|
@ -141,6 +161,7 @@ type ListOption struct {
|
|||
Page int `json:"page,omitempty"` // Page number (1-based), default is 1
|
||||
PageSize int `json:"page_size,omitempty"` // Page size, default is 20
|
||||
Filters map[string]interface{} `json:"filters,omitempty"` // Filter conditions, e.g., {"status": "uploaded", "content_type": "image/*"}
|
||||
Wheres []model.QueryWhere `json:"wheres,omitempty"` // Advanced where clauses for permission filtering
|
||||
OrderBy string `json:"order_by,omitempty"` // Order by field, e.g., "created_at desc", "name asc"
|
||||
Select []string `json:"select,omitempty"` // Fields to select, empty means select all
|
||||
}
|
||||
|
|
|
|||
284
data/bindata.go
284
data/bindata.go
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,9 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
|
@ -85,61 +87,11 @@ func upload(c *gin.Context) {
|
|||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get original filename from form data
|
||||
originalFilename := c.PostForm("original_filename")
|
||||
if originalFilename == "" {
|
||||
originalFilename = fileHeader.Filename
|
||||
}
|
||||
|
||||
// Get path from form data for user_path
|
||||
userPath := c.PostForm("path")
|
||||
if userPath == "" {
|
||||
userPath = originalFilename
|
||||
}
|
||||
|
||||
// Parse groups from form data
|
||||
var groups []string
|
||||
groupsStr := c.PostForm("groups")
|
||||
if groupsStr != "" {
|
||||
groups = strings.Split(groupsStr, ",")
|
||||
// Trim spaces
|
||||
for i, group := range groups {
|
||||
groups[i] = strings.TrimSpace(group)
|
||||
}
|
||||
}
|
||||
|
||||
// Create upload header from request
|
||||
header := attachment.GetHeader(c.Request.Header, fileHeader.Header, fileHeader.Size)
|
||||
|
||||
// Parse gzip option
|
||||
gzip := false
|
||||
if gzipStr := c.PostForm("gzip"); gzipStr == "true" {
|
||||
gzip = true
|
||||
}
|
||||
|
||||
// Parse compress image options
|
||||
compressImage := false
|
||||
if compressImageStr := c.PostForm("compress_image"); compressImageStr == "true" {
|
||||
compressImage = true
|
||||
}
|
||||
|
||||
compressSize := 0
|
||||
if compressSizeStr := c.PostForm("compress_size"); compressSizeStr != "" {
|
||||
if size, err := strconv.Atoi(compressSizeStr); err == nil && size > 0 {
|
||||
compressSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// Create upload options
|
||||
uploadOption := attachment.UploadOption{
|
||||
OriginalFilename: originalFilename, // Use original filename from form data
|
||||
Groups: groups, // Groups for directory structure
|
||||
ClientID: c.PostForm("client_id"),
|
||||
OpenID: c.PostForm("openid"),
|
||||
Gzip: gzip, // Gzip compression
|
||||
CompressImage: compressImage, // Image compression
|
||||
CompressSize: compressSize, // Compression size
|
||||
}
|
||||
// Create upload options with all parameters parsed from form data
|
||||
uploadOption := createUploadOption(c, fileHeader.Filename)
|
||||
|
||||
// Upload the file
|
||||
uploadedFile, err := manager.Upload(c.Request.Context(), header, file, uploadOption)
|
||||
|
|
@ -194,6 +146,9 @@ func list(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Get auth info for permission filtering
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Parse filters
|
||||
filters := make(map[string]interface{})
|
||||
filters["uploader"] = uploaderID // Always filter by current uploader
|
||||
|
|
@ -208,6 +163,18 @@ func list(c *gin.Context) {
|
|||
filters["name"] = name + "*" // Wildcard search
|
||||
}
|
||||
|
||||
// Build where clauses for permission-based filtering
|
||||
var wheres []model.QueryWhere
|
||||
|
||||
// Add basic filters as where clauses
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Column: "uploader",
|
||||
Value: uploaderID,
|
||||
})
|
||||
|
||||
// Apply permission-based filtering
|
||||
wheres = append(wheres, AuthFilter(c, authInfo)...)
|
||||
|
||||
// Parse order by
|
||||
orderBy := c.Query("order_by")
|
||||
if orderBy == "" {
|
||||
|
|
@ -223,11 +190,12 @@ func list(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Create list option
|
||||
// Create list option with where clauses
|
||||
listOption := attachment.ListOption{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Filters: filters,
|
||||
Wheres: wheres,
|
||||
OrderBy: orderBy,
|
||||
Select: selectFields,
|
||||
}
|
||||
|
|
@ -281,7 +249,7 @@ func retrieve(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get file info using the new Info method
|
||||
// Get file info (includes permission fields)
|
||||
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
|
|
@ -292,6 +260,27 @@ func retrieve(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check read permission using file info
|
||||
authInfo := authorized.GetInfo(c)
|
||||
hasPermission, err := checkFilePermission(authInfo, fileInfo, true) // true = readable mode
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasPermission {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to access file",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the file info
|
||||
response.RespondWithSuccess(c, response.StatusOK, fileInfo)
|
||||
}
|
||||
|
|
@ -330,8 +319,9 @@ func delete(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if file exists first
|
||||
if !manager.Exists(c.Request.Context(), fileID) {
|
||||
// Get file info first (includes permission fields)
|
||||
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "File not found",
|
||||
|
|
@ -340,8 +330,29 @@ func delete(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
err := manager.Delete(c.Request.Context(), fileID)
|
||||
// Check delete permission using file info (false = write permission required)
|
||||
authInfo := authorized.GetInfo(c)
|
||||
hasPermission, err := checkFilePermission(authInfo, fileInfo, false) // false = write permission required
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasPermission {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to delete file",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the file (permission already checked)
|
||||
err = manager.Delete(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
|
|
@ -392,7 +403,7 @@ func content(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get file info first to obtain metadata
|
||||
// Get file info (includes permission fields)
|
||||
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
|
|
@ -403,8 +414,29 @@ func content(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check read permission using file info
|
||||
authInfo := authorized.GetInfo(c)
|
||||
hasPermission, err := checkFilePermission(authInfo, fileInfo, true) // true = readable mode
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasPermission {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to access file content",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
content, err := manager.Read(c.Request.Context(), fileID)
|
||||
fileContent, err := manager.Read(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
|
|
@ -419,10 +451,10 @@ func content(c *gin.Context) {
|
|||
if fileInfo.Filename != "" {
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileInfo.Filename))
|
||||
}
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(fileContent)))
|
||||
|
||||
// Return file content directly
|
||||
c.Data(http.StatusOK, fileInfo.ContentType, content)
|
||||
c.Data(http.StatusOK, fileInfo.ContentType, fileContent)
|
||||
}
|
||||
|
||||
// exists checks if a file exists
|
||||
|
|
@ -468,3 +500,123 @@ func exists(c *gin.Context) {
|
|||
}
|
||||
response.RespondWithSuccess(c, response.StatusOK, successData)
|
||||
}
|
||||
|
||||
// createUploadOption creates an UploadOption from request context and form data
|
||||
// Parses all upload parameters including auth info, permission fields, and upload options
|
||||
func createUploadOption(c *gin.Context, defaultFilename string) attachment.UploadOption {
|
||||
option := attachment.UploadOption{}
|
||||
|
||||
// Parse original filename from form data
|
||||
originalFilename := c.PostForm("original_filename")
|
||||
if originalFilename == "" {
|
||||
originalFilename = defaultFilename
|
||||
}
|
||||
option.OriginalFilename = originalFilename
|
||||
|
||||
// Parse groups from form data
|
||||
if groupsStr := c.PostForm("groups"); groupsStr != "" {
|
||||
groups := strings.Split(groupsStr, ",")
|
||||
// Trim spaces from each group
|
||||
for i, group := range groups {
|
||||
groups[i] = strings.TrimSpace(group)
|
||||
}
|
||||
option.Groups = groups
|
||||
}
|
||||
|
||||
// Parse gzip option
|
||||
if gzipStr := c.PostForm("gzip"); gzipStr == "true" || gzipStr == "1" {
|
||||
option.Gzip = true
|
||||
}
|
||||
|
||||
// Parse compress image options
|
||||
if compressImageStr := c.PostForm("compress_image"); compressImageStr == "true" || compressImageStr == "1" {
|
||||
option.CompressImage = true
|
||||
}
|
||||
|
||||
// Parse compress size
|
||||
if compressSizeStr := c.PostForm("compress_size"); compressSizeStr != "" {
|
||||
if size, err := strconv.Atoi(compressSizeStr); err == nil && size > 0 {
|
||||
option.CompressSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// Extract auth info from context (set by OAuth guard middleware)
|
||||
authInfo := authorized.GetInfo(c)
|
||||
if authInfo != nil {
|
||||
// Set Yao permission fields from authenticated user info
|
||||
// Note: YaoUpdatedBy is not set on upload (creation), only on update
|
||||
if authInfo.UserID != "" {
|
||||
option.YaoCreatedBy = authInfo.UserID
|
||||
}
|
||||
if authInfo.TeamID != "" {
|
||||
option.YaoTeamID = authInfo.TeamID
|
||||
}
|
||||
if authInfo.TenantID != "" {
|
||||
option.YaoTenantID = authInfo.TenantID
|
||||
}
|
||||
}
|
||||
|
||||
// Parse public field from form data (user can override)
|
||||
if publicStr := c.PostForm("public"); publicStr != "" {
|
||||
if publicStr == "true" || publicStr == "1" {
|
||||
option.Public = true
|
||||
} else {
|
||||
option.Public = false
|
||||
}
|
||||
}
|
||||
|
||||
// Parse share field from form data (user can override)
|
||||
// Valid values: "private", "team"
|
||||
if shareStr := c.PostForm("share"); shareStr != "" {
|
||||
shareStr = strings.TrimSpace(strings.ToLower(shareStr))
|
||||
if shareStr == "private" || shareStr == "team" {
|
||||
option.Share = shareStr
|
||||
}
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
// checkFilePermission checks if the user has permission to access the file
|
||||
func checkFilePermission(authInfo *types.AuthorizedInfo, fileInfo *attachment.File, readable ...bool) (bool, error) {
|
||||
// No auth info, allow access
|
||||
if authInfo == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// No constraints, allow access
|
||||
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If readable mode and file is public, allow access
|
||||
if len(readable) > 0 && readable[0] {
|
||||
if fileInfo.Public {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If file is shared with team and user is in the same team, allow access
|
||||
if fileInfo.Share == "team" && authInfo.Constraints.TeamOnly && fileInfo.YaoTeamID == authInfo.TeamID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Combined Team and Owner permission validation
|
||||
if authInfo.Constraints.TeamOnly && authInfo.Constraints.OwnerOnly {
|
||||
if fileInfo.YaoCreatedBy == authInfo.UserID && fileInfo.YaoTeamID == authInfo.TeamID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Owner only permission validation
|
||||
if authInfo.Constraints.OwnerOnly && fileInfo.YaoCreatedBy == authInfo.UserID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Team only permission validation
|
||||
if authInfo.Constraints.TeamOnly && fileInfo.YaoTeamID == authInfo.TeamID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
|
|
|||
68
openapi/file/filter.go
Normal file
68
openapi/file/filter.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// AuthFilter applies permission-based filtering to file query wheres
|
||||
// This function builds where clauses based on the user's authorization constraints
|
||||
// It supports TeamOnly and OwnerOnly constraints for file access control
|
||||
//
|
||||
// Parameters:
|
||||
// - c: gin.Context containing authorization information
|
||||
// - authInfo: authorized information extracted from the context
|
||||
//
|
||||
// Returns:
|
||||
// - []model.QueryWhere: array of where clauses to apply to the query
|
||||
func AuthFilter(c *gin.Context, authInfo *types.AuthorizedInfo) []model.QueryWhere {
|
||||
if authInfo == nil {
|
||||
return []model.QueryWhere{}
|
||||
}
|
||||
|
||||
var wheres []model.QueryWhere
|
||||
scope := authInfo.AccessScope()
|
||||
|
||||
// Team only - User can access:
|
||||
// 1. Public files (public = true)
|
||||
// 2. Files in their team where:
|
||||
// - They uploaded the file (__yao_created_by matches)
|
||||
// - OR the file is shared with team (share = "team")
|
||||
if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) {
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "public", Value: true, Method: "orwhere"},
|
||||
{Wheres: []model.QueryWhere{
|
||||
{Column: "__yao_team_id", Value: scope.TeamID},
|
||||
{Wheres: []model.QueryWhere{
|
||||
{Column: "__yao_created_by", Value: scope.CreatedBy},
|
||||
{Column: "share", Value: "team", Method: "orwhere"},
|
||||
}},
|
||||
}, Method: "orwhere"},
|
||||
},
|
||||
})
|
||||
return wheres
|
||||
}
|
||||
|
||||
// Owner only - User can access:
|
||||
// 1. Public files (public = true)
|
||||
// 2. Files they uploaded where:
|
||||
// - __yao_team_id is null (not team files)
|
||||
// - __yao_created_by matches their user ID
|
||||
if authInfo.Constraints.OwnerOnly && authInfo.UserID != "" {
|
||||
wheres = append(wheres, model.QueryWhere{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "public", Value: true, Method: "orwhere"},
|
||||
{Wheres: []model.QueryWhere{
|
||||
{Column: "__yao_team_id", OP: "null"},
|
||||
{Column: "__yao_created_by", Value: scope.CreatedBy},
|
||||
}, Method: "orwhere"},
|
||||
},
|
||||
})
|
||||
return wheres
|
||||
}
|
||||
|
||||
return wheres
|
||||
}
|
||||
|
|
@ -132,8 +132,8 @@ func TestFileUpload(t *testing.T) {
|
|||
"original_filename": testFileName,
|
||||
"path": "documents/reports/quarterly-report.txt",
|
||||
"groups": "documents,reports",
|
||||
"client_id": "test-client",
|
||||
"openid": "test-user",
|
||||
"public": "false",
|
||||
"share": "private",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
|
|
@ -1008,3 +1008,96 @@ func TestFileIntegration(t *testing.T) {
|
|||
t.Logf("Completed full file lifecycle test for: %s", testFileID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFilePermissionFields tests the new permission and auth fields
|
||||
func TestFilePermissionFields(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
setupTestUploader(t)
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
client := testutils.RegisterTestClient(t, "File Permission Test Client", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
t.Run("UploadWithPublicTeamShare", func(t *testing.T) {
|
||||
// Upload file with public=true and share=team
|
||||
requestURL := serverURL + baseURL + "/file/" + testUploaderID
|
||||
req, err := createMultipartRequest(requestURL, "file", "public-team-file.txt", []byte("Public team content"), map[string]string{
|
||||
"original_filename": "public-team-file.txt",
|
||||
"groups": "shared,public",
|
||||
"public": "true",
|
||||
"share": "team",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "file_id")
|
||||
t.Logf("Successfully uploaded public team file: %s", response["file_id"])
|
||||
})
|
||||
|
||||
t.Run("UploadWithPrivateShare", func(t *testing.T) {
|
||||
// Upload file with public=false and share=private (default)
|
||||
requestURL := serverURL + baseURL + "/file/" + testUploaderID
|
||||
req, err := createMultipartRequest(requestURL, "file", "private-file.txt", []byte("Private content"), map[string]string{
|
||||
"original_filename": "private-file.txt",
|
||||
"groups": "personal",
|
||||
"public": "false",
|
||||
"share": "private",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "file_id")
|
||||
t.Logf("Successfully uploaded private file: %s", response["file_id"])
|
||||
})
|
||||
|
||||
t.Run("UploadWithoutPermissionFields", func(t *testing.T) {
|
||||
// Upload file without specifying public/share (should use defaults)
|
||||
requestURL := serverURL + baseURL + "/file/" + testUploaderID
|
||||
req, err := createMultipartRequest(requestURL, "file", "default-permissions.txt", []byte("Default permissions content"), map[string]string{
|
||||
"original_filename": "default-permissions.txt",
|
||||
"groups": "defaults",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "file_id")
|
||||
t.Logf("Successfully uploaded file with default permissions: %s", response["file_id"])
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,26 +154,41 @@
|
|||
"length": 600,
|
||||
"nullable": true
|
||||
},
|
||||
|
||||
{
|
||||
"name": "client_id",
|
||||
"type": "string",
|
||||
"label": "Client ID",
|
||||
"comment": "Client identifier",
|
||||
"length": 255,
|
||||
"nullable": true,
|
||||
"index": true
|
||||
"name": "preset",
|
||||
"type": "boolean",
|
||||
"label": "Preset Attachment",
|
||||
"comment": "Whether this is a preset attachment",
|
||||
"default": false,
|
||||
"nullable": false
|
||||
},
|
||||
|
||||
{
|
||||
"name": "openid",
|
||||
"type": "string",
|
||||
"label": "OpenID",
|
||||
"comment": "OpenID identifier",
|
||||
"length": 255,
|
||||
"nullable": true,
|
||||
"name": "public",
|
||||
"type": "boolean",
|
||||
"label": "Public Attachment",
|
||||
"comment": "Whether this attachment is shared across all teams in the platform",
|
||||
"default": false,
|
||||
"nullable": false
|
||||
},
|
||||
|
||||
// Custom permissions
|
||||
{
|
||||
"name": "share",
|
||||
"type": "enum",
|
||||
"label": "Share",
|
||||
"comment": "Attachment sharing scope",
|
||||
"option": [
|
||||
"private", // Only visible to the owner
|
||||
"team" // Visible to all team members
|
||||
],
|
||||
"default": "private",
|
||||
"nullable": false,
|
||||
"index": true
|
||||
}
|
||||
],
|
||||
"relations": {},
|
||||
"indexes": [],
|
||||
"option": { "timestamps": true, "soft_deletes": false }
|
||||
"option": { "timestamps": true, "soft_deletes": false, "permission": true }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue