Enhance attachment management with public and share fields
- Introduced new fields in the attachment model for public access control and sharing options, allowing attachments to be marked as public or shared with specific teams. - Updated the upload process to handle new permission fields, ensuring proper handling of public and share options during file uploads. - Implemented permission checks in the file retrieval and deletion processes to enforce access control based on user roles and attachment settings. - Added comprehensive tests for the new permission fields to validate functionality and ensure correct behavior in various scenarios.
This commit is contained in:
parent
27d8378511
commit
5d18e76acd
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
|
// Add select fields
|
||||||
if len(option.Select) > 0 {
|
if len(option.Select) > 0 {
|
||||||
queryParam.Select = make([]interface{}, len(option.Select))
|
queryParam.Select = make([]interface{}, 0, len(option.Select))
|
||||||
for i, field := range option.Select {
|
for _, field := range option.Select {
|
||||||
queryParam.Select[i] = field
|
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
|
// Add ordering
|
||||||
if option.OrderBy != "" {
|
if option.OrderBy != "" {
|
||||||
// Parse order by string like "created_at desc" or "name asc"
|
// 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")
|
m := model.Select("__yao.attachment")
|
||||||
|
|
||||||
|
// Set default value for share if empty
|
||||||
|
share := option.Share
|
||||||
|
if share == "" {
|
||||||
|
share = "private"
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare data for database
|
// Prepare data for database
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"file_id": file.ID,
|
"file_id": file.ID,
|
||||||
|
|
@ -1092,8 +1106,22 @@ func (manager Manager) saveFileToDatabase(ctx context.Context, file *File, stora
|
||||||
"status": file.Status,
|
"status": file.Status,
|
||||||
"gzip": option.Gzip,
|
"gzip": option.Gzip,
|
||||||
"groups": option.Groups,
|
"groups": option.Groups,
|
||||||
"client_id": option.ClientID,
|
"public": option.Public,
|
||||||
"openid": option.OpenID,
|
"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
|
// Check if record exists first
|
||||||
|
|
@ -1128,9 +1156,14 @@ func (manager Manager) getFileFromDatabase(ctx context.Context, fileID string) (
|
||||||
m := model.Select("__yao.attachment")
|
m := model.Select("__yao.attachment")
|
||||||
|
|
||||||
records, err := m.Get(model.QueryParam{
|
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{
|
Wheres: []model.QueryWhere{
|
||||||
{Column: "file_id", Value: fileID},
|
{Column: "file_id", Value: fileID},
|
||||||
},
|
},
|
||||||
|
Limit: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1165,6 +1198,13 @@ func (manager Manager) getFileFromDatabase(ctx context.Context, fileID string) (
|
||||||
file.Bytes = int(bytes)
|
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
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -556,8 +556,8 @@ func TestInfo(t *testing.T) {
|
||||||
option := UploadOption{
|
option := UploadOption{
|
||||||
Groups: []string{"info", "test"},
|
Groups: []string{"info", "test"},
|
||||||
OriginalFilename: "original-info-test.txt",
|
OriginalFilename: "original-info-test.txt",
|
||||||
ClientID: "test-client-123",
|
Public: false,
|
||||||
OpenID: "test-openid-456",
|
Share: "private",
|
||||||
Gzip: false,
|
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) {
|
func TestManagerLocalPath_ValidationFlow(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/gou/types"
|
"github.com/yaoapp/gou/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -58,6 +59,13 @@ type File struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Status string `json:"status"` // uploading, uploaded, indexing, indexed, upload_failed, index_failed
|
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
|
// FileResponse represents a file download response
|
||||||
|
|
@ -80,9 +88,15 @@ type Attachment struct {
|
||||||
UserPath string `json:"user_path,omitempty"` // User-specified complete file path
|
UserPath string `json:"user_path,omitempty"` // User-specified complete file path
|
||||||
Path string `json:"path,omitempty"` // Actual storage path
|
Path string `json:"path,omitempty"` // Actual storage path
|
||||||
Groups []string `json:"groups,omitempty"`
|
Groups []string `json:"groups,omitempty"`
|
||||||
Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false
|
Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false
|
||||||
ClientID string `json:"client_id,omitempty"` // Client identifier
|
Public bool `json:"public,omitempty"` // Whether this attachment is shared across all teams in the platform
|
||||||
OpenID string `json:"openid,omitempty"` // OpenID identifier
|
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
|
// 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
|
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
|
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"]
|
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
|
Public bool `json:"public,omitempty" form:"public"` // Whether this attachment is shared across all teams in the platform
|
||||||
OpenID string `json:"openid,omitempty" form:"openid"` // OpenID identifier
|
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
|
// 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
|
Page int `json:"page,omitempty"` // Page number (1-based), default is 1
|
||||||
PageSize int `json:"page_size,omitempty"` // Page size, default is 20
|
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/*"}
|
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"
|
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
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/yao/attachment"
|
"github.com/yaoapp/yao/attachment"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
@ -85,61 +87,11 @@ func upload(c *gin.Context) {
|
||||||
}
|
}
|
||||||
defer file.Close()
|
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
|
// Create upload header from request
|
||||||
header := attachment.GetHeader(c.Request.Header, fileHeader.Header, fileHeader.Size)
|
header := attachment.GetHeader(c.Request.Header, fileHeader.Header, fileHeader.Size)
|
||||||
|
|
||||||
// Parse gzip option
|
// Create upload options with all parameters parsed from form data
|
||||||
gzip := false
|
uploadOption := createUploadOption(c, fileHeader.Filename)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload the file
|
// Upload the file
|
||||||
uploadedFile, err := manager.Upload(c.Request.Context(), header, file, uploadOption)
|
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
|
// Parse filters
|
||||||
filters := make(map[string]interface{})
|
filters := make(map[string]interface{})
|
||||||
filters["uploader"] = uploaderID // Always filter by current uploader
|
filters["uploader"] = uploaderID // Always filter by current uploader
|
||||||
|
|
@ -208,6 +163,18 @@ func list(c *gin.Context) {
|
||||||
filters["name"] = name + "*" // Wildcard search
|
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
|
// Parse order by
|
||||||
orderBy := c.Query("order_by")
|
orderBy := c.Query("order_by")
|
||||||
if orderBy == "" {
|
if orderBy == "" {
|
||||||
|
|
@ -223,11 +190,12 @@ func list(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create list option
|
// Create list option with where clauses
|
||||||
listOption := attachment.ListOption{
|
listOption := attachment.ListOption{
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
Filters: filters,
|
Filters: filters,
|
||||||
|
Wheres: wheres,
|
||||||
OrderBy: orderBy,
|
OrderBy: orderBy,
|
||||||
Select: selectFields,
|
Select: selectFields,
|
||||||
}
|
}
|
||||||
|
|
@ -281,7 +249,7 @@ func retrieve(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file info using the new Info method
|
// Get file info (includes permission fields)
|
||||||
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
|
|
@ -292,6 +260,27 @@ func retrieve(c *gin.Context) {
|
||||||
return
|
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
|
// Return the file info
|
||||||
response.RespondWithSuccess(c, response.StatusOK, fileInfo)
|
response.RespondWithSuccess(c, response.StatusOK, fileInfo)
|
||||||
}
|
}
|
||||||
|
|
@ -330,8 +319,9 @@ func delete(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists first
|
// Get file info first (includes permission fields)
|
||||||
if !manager.Exists(c.Request.Context(), fileID) {
|
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||||
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidRequest.Code,
|
Code: response.ErrInvalidRequest.Code,
|
||||||
ErrorDescription: "File not found",
|
ErrorDescription: "File not found",
|
||||||
|
|
@ -340,8 +330,29 @@ func delete(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the file
|
// Check delete permission using file info (false = write permission required)
|
||||||
err := manager.Delete(c.Request.Context(), fileID)
|
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 {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
|
|
@ -392,7 +403,7 @@ func content(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file info first to obtain metadata
|
// Get file info (includes permission fields)
|
||||||
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
|
|
@ -403,8 +414,29 @@ func content(c *gin.Context) {
|
||||||
return
|
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
|
// Read the file content
|
||||||
content, err := manager.Read(c.Request.Context(), fileID)
|
fileContent, err := manager.Read(c.Request.Context(), fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrServerError.Code,
|
Code: response.ErrServerError.Code,
|
||||||
|
|
@ -419,10 +451,10 @@ func content(c *gin.Context) {
|
||||||
if fileInfo.Filename != "" {
|
if fileInfo.Filename != "" {
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", 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
|
// Return file content directly
|
||||||
c.Data(http.StatusOK, fileInfo.ContentType, content)
|
c.Data(http.StatusOK, fileInfo.ContentType, fileContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// exists checks if a file exists
|
// exists checks if a file exists
|
||||||
|
|
@ -468,3 +500,123 @@ func exists(c *gin.Context) {
|
||||||
}
|
}
|
||||||
response.RespondWithSuccess(c, response.StatusOK, successData)
|
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,
|
"original_filename": testFileName,
|
||||||
"path": "documents/reports/quarterly-report.txt",
|
"path": "documents/reports/quarterly-report.txt",
|
||||||
"groups": "documents,reports",
|
"groups": "documents,reports",
|
||||||
"client_id": "test-client",
|
"public": "false",
|
||||||
"openid": "test-user",
|
"share": "private",
|
||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -1008,3 +1008,96 @@ func TestFileIntegration(t *testing.T) {
|
||||||
t.Logf("Completed full file lifecycle test for: %s", testFileID)
|
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,
|
"length": 600,
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "client_id",
|
"name": "preset",
|
||||||
"type": "string",
|
"type": "boolean",
|
||||||
"label": "Client ID",
|
"label": "Preset Attachment",
|
||||||
"comment": "Client identifier",
|
"comment": "Whether this is a preset attachment",
|
||||||
"length": 255,
|
"default": false,
|
||||||
"nullable": true,
|
"nullable": false
|
||||||
"index": true
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "openid",
|
"name": "public",
|
||||||
"type": "string",
|
"type": "boolean",
|
||||||
"label": "OpenID",
|
"label": "Public Attachment",
|
||||||
"comment": "OpenID identifier",
|
"comment": "Whether this attachment is shared across all teams in the platform",
|
||||||
"length": 255,
|
"default": false,
|
||||||
"nullable": true,
|
"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
|
"index": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"relations": {},
|
"relations": {},
|
||||||
"indexes": [],
|
"indexes": [],
|
||||||
"option": { "timestamps": true, "soft_deletes": false }
|
"option": { "timestamps": true, "soft_deletes": false, "permission": true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue