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:
Max 2025-11-05 18:11:36 +08:00
parent 27d8378511
commit 5d18e76acd
9 changed files with 1049 additions and 232 deletions

61
attachment/convert.go Normal file
View 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)
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -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
@ -81,8 +89,14 @@ type Attachment struct {
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
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
}

File diff suppressed because it is too large Load diff

View file

@ -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
View 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
}

View file

@ -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"])
})
}

View file

@ -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 }
}