package attachment import ( "archive/zip" "bytes" "context" "encoding/base64" "fmt" "mime" "mime/multipart" "net/textproto" "path/filepath" "strings" "github.com/yaoapp/gou/model" "github.com/yaoapp/gou/process" "github.com/yaoapp/kun/any" "github.com/yaoapp/kun/maps" ) // Init registers all attachment processes func Init() { process.RegisterGroup("attachment", map[string]process.Handler{ "Save": processSave, "Read": processRead, "Info": processInfo, "List": processList, "Delete": processDelete, "Exists": processExists, "URL": processURL, "SaveText": processSaveText, "GetText": processGetText, "Zip": processZip, }) } // processSave saves a file from base64 data URI // Args: // - uploaderID: string - the uploader/manager ID // - content: string - base64 data URI (e.g., "data:image/png;base64,xxxx") or plain base64 // - filename: string (optional) - original filename // - option: map (optional) - upload options (groups, gzip, compress_image, public, share) // // Returns: *File - uploaded file info // // Example: // // Process("attachment.Save", "default", "data:image/png;base64,iVBORw0KGgo...", "photo.png") // Process("attachment.Save", "default", "data:text/plain;base64,SGVsbG8=", "hello.txt", {"share": "team"}) func processSave(p *process.Process) interface{} { p.ValidateArgNums(2) uploaderID := p.ArgsString(0) content := p.ArgsString(1) // Get manager manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } // Parse data URI and decode content contentType, data, err := parseDataURI(content) if err != nil { return fmt.Errorf("failed to parse content: %v", err) } // Get filename from args or generate from content type filename := "" if p.NumOfArgs() > 2 { filename = p.ArgsString(2) } if filename == "" { filename = generateFilename(contentType) } // Create file header header := createFileHeader(filename, contentType, int64(len(data))) // Create upload options option := createUploadOption(p, filename) // Upload ctx := context.Background() file, err := manager.Upload(ctx, header, strings.NewReader(string(data)), option) if err != nil { return fmt.Errorf("failed to save file: %v", err) } return file } // processRead reads file content as base64 data URI // Args: // - uploaderID: string - the uploader/manager ID // - fileID: string - the file ID // // Returns: string - base64 data URI (e.g., "data:image/png;base64,xxxx") // // Example: // // const dataURI = Process("attachment.Read", "default", "abc123") func processRead(p *process.Process) interface{} { p.ValidateArgNums(2) uploaderID := p.ArgsString(0) fileID := p.ArgsString(1) manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() // Get file info for content type and permission check fileInfo, err := manager.Info(ctx, fileID) if err != nil { return fmt.Errorf("file not found: %v", err) } // Check permission if err := checkFilePermission(p, fileInfo, true); err != nil { return err } // Read content as base64 base64Data, err := manager.ReadBase64(ctx, fileID) if err != nil { return fmt.Errorf("failed to read file: %v", err) } // Return as data URI return fmt.Sprintf("data:%s;base64,%s", fileInfo.ContentType, base64Data) } // processInfo gets file information // Args: // - uploaderID: string - the uploader/manager ID // - fileID: string - the file ID // // Returns: *File - file info func processInfo(p *process.Process) interface{} { p.ValidateArgNums(2) uploaderID := p.ArgsString(0) fileID := p.ArgsString(1) manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() fileInfo, err := manager.Info(ctx, fileID) if err != nil { return fmt.Errorf("file not found: %v", err) } // Check permission if err := checkFilePermission(p, fileInfo, true); err != nil { return err } return fileInfo } // processList lists files with pagination and filtering // Args: // - uploaderID: string - the uploader/manager ID // - option: map (optional) - list options (page, page_size, filters, order_by, select) // // Returns: *ListResult - paginated file list func processList(p *process.Process) interface{} { p.ValidateArgNums(1) uploaderID := p.ArgsString(0) manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } // Parse list options listOption := ListOption{ Page: 1, PageSize: 20, } if p.NumOfArgs() > 1 { optionRaw := p.ArgsMap(1) option := maps.MapOf(optionRaw).Dot() if page := any.Of(option.Get("page")).CInt(); page > 0 { listOption.Page = page } if pageSize := any.Of(option.Get("page_size")).CInt(); pageSize > 0 && pageSize <= 100 { listOption.PageSize = pageSize } if filters, ok := option.Get("filters").(map[string]interface{}); ok { listOption.Filters = filters } if orderBy, ok := option.Get("order_by").(string); ok { listOption.OrderBy = orderBy } if selectFields, ok := option.Get("select").([]interface{}); ok { for _, field := range selectFields { if f, ok := field.(string); ok { listOption.Select = append(listOption.Select, f) } } } } // Always filter by uploader if listOption.Filters == nil { listOption.Filters = make(map[string]interface{}) } listOption.Filters["uploader"] = uploaderID // Add permission-based filtering listOption.Wheres = append(listOption.Wheres, model.QueryWhere{ Column: "uploader", Value: uploaderID, }) listOption.Wheres = append(listOption.Wheres, buildPermissionWheres(p)...) ctx := context.Background() result, err := manager.List(ctx, listOption) if err != nil { return fmt.Errorf("failed to list files: %v", err) } return result } // processDelete deletes a file // Args: // - uploaderID: string - the uploader/manager ID // - fileID: string - the file ID // // Returns: bool - success func processDelete(p *process.Process) interface{} { p.ValidateArgNums(2) uploaderID := p.ArgsString(0) fileID := p.ArgsString(1) manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() // Get file info first fileInfo, err := manager.Info(ctx, fileID) if err != nil { return fmt.Errorf("file not found: %v", err) } // Check write permission if err := checkFilePermission(p, fileInfo, false); err != nil { return err } // Delete file if err := manager.Delete(ctx, fileID); err != nil { return fmt.Errorf("failed to delete file: %v", err) } return true } // processExists checks if file exists // Args: // - uploaderID: string - the uploader/manager ID // - fileID: string - the file ID // // Returns: bool func processExists(p *process.Process) interface{} { p.ValidateArgNums(2) uploaderID := p.ArgsString(0) fileID := p.ArgsString(1) manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() return manager.Exists(ctx, fileID) } // processURL gets file URL // Args: // - uploaderID: string - the uploader/manager ID // - fileID: string - the file ID // // Returns: string - file URL func processURL(p *process.Process) interface{} { p.ValidateArgNums(2) uploaderID := p.ArgsString(0) fileID := p.ArgsString(1) manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() // Get file info for permission check fileInfo, err := manager.Info(ctx, fileID) if err != nil { return fmt.Errorf("file not found: %v", err) } // Check permission if err := checkFilePermission(p, fileInfo, true); err != nil { return err } return manager.storage.URL(ctx, fileID) } // processSaveText saves parsed text content for a file // Args: // - uploaderID: string - the uploader/manager ID // - fileID: string - the file ID // - text: string - the text content to save // // Returns: bool - success func processSaveText(p *process.Process) interface{} { p.ValidateArgNums(3) uploaderID := p.ArgsString(0) fileID := p.ArgsString(1) text := p.ArgsString(2) manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() // Get file info first to check write permission fileInfo, err := manager.Info(ctx, fileID) if err != nil { return fmt.Errorf("file not found: %v", err) } // Check write permission if err := checkFilePermission(p, fileInfo, false); err != nil { return err } if err := manager.SaveText(ctx, fileID, text); err != nil { return fmt.Errorf("failed to save text: %v", err) } return true } // processGetText gets parsed text content for a file // Args: // - uploaderID: string - the uploader/manager ID // - fileID: string - the file ID // - fullContent: bool (optional) - whether to get full content (default: false, returns preview) // // Returns: string - text content func processGetText(p *process.Process) interface{} { p.ValidateArgNums(2) uploaderID := p.ArgsString(0) fileID := p.ArgsString(1) fullContent := false if p.NumOfArgs() > 2 { fullContent = p.ArgsBool(2) } manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() // Get file info for permission check fileInfo, err := manager.Info(ctx, fileID) if err != nil { return fmt.Errorf("file not found: %v", err) } // Check permission if err := checkFilePermission(p, fileInfo, true); err != nil { return err } text, err := manager.GetText(ctx, fileID, fullContent) if err != nil { return fmt.Errorf("failed to get text: %v", err) } return text } // ============ Helper Functions ============ // parseDataURI parses content as either: // 1. Data URI format: data:image/png;base64,xxxxx (decoded from base64) // 2. Plain text: stored as-is with text/plain content type // // Returns content type, data bytes, and error func parseDataURI(content string) (string, []byte, error) { // Handle data URI format: data:image/png;base64,xxxxx if strings.HasPrefix(content, "data:") { // Split by comma to get the data part parts := strings.SplitN(content, ",", 2) if len(parts) != 2 { return "", nil, fmt.Errorf("invalid data URI format") } // Parse the header: data:image/png;base64 header := parts[0] base64Content := parts[1] // Extract content type from header contentType := "application/octet-stream" header = strings.TrimPrefix(header, "data:") headerParts := strings.Split(header, ";") if len(headerParts) > 0 && headerParts[0] != "" { contentType = headerParts[0] } // Decode base64 data, err := base64.StdEncoding.DecodeString(base64Content) if err != nil { return "", nil, fmt.Errorf("failed to decode base64: %v", err) } return contentType, data, nil } // Plain text content - store as-is return "text/plain", []byte(content), nil } // generateFilename generates a filename based on content type func generateFilename(contentType string) string { // Get extension from content type exts, err := mime.ExtensionsByType(contentType) if err == nil && len(exts) > 0 { return "file" + exts[0] } // Fallback for common types switch contentType { case "image/png": return "file.png" case "image/jpeg": return "file.jpg" case "image/gif": return "file.gif" case "image/webp": return "file.webp" case "text/plain": return "file.txt" case "application/pdf": return "file.pdf" case "application/json": return "file.json" default: return "file.bin" } } // createUploadOption creates UploadOption from process args func createUploadOption(p *process.Process, filename string) UploadOption { option := UploadOption{ OriginalFilename: filename, } // Parse option from fourth argument if provided if p.NumOfArgs() > 3 { optionRaw := p.ArgsMap(3) optionMap := maps.MapOf(optionRaw).Dot() // Groups if groups, ok := optionMap.Get("groups").([]interface{}); ok { for _, g := range groups { if gs, ok := g.(string); ok { option.Groups = append(option.Groups, gs) } } } else if groupsStr, ok := optionMap.Get("groups").(string); ok { option.Groups = strings.Split(groupsStr, ",") for i := range option.Groups { option.Groups[i] = strings.TrimSpace(option.Groups[i]) } } // Gzip if gzip, ok := optionMap.Get("gzip").(bool); ok { option.Gzip = gzip } // Compress image if compress, ok := optionMap.Get("compress_image").(bool); ok { option.CompressImage = compress } if size := any.Of(optionMap.Get("compress_size")).CInt(); size > 0 { option.CompressSize = size } // Public/Share if public, ok := optionMap.Get("public").(bool); ok { option.Public = public } if share, ok := optionMap.Get("share").(string); ok { option.Share = share } } // Set permission fields from process.Authorized if p.Authorized != nil { option.YaoCreatedBy = p.Authorized.UserID option.YaoTeamID = p.Authorized.TeamID option.YaoTenantID = p.Authorized.TenantID } return option } // createFileHeader creates a FileHeader from parameters func createFileHeader(filename, contentType string, size int64) *FileHeader { header := &multipart.FileHeader{ Filename: filename, Size: size, Header: make(textproto.MIMEHeader), } header.Header.Set("Content-Type", contentType) // Set extension from filename if ext := filepath.Ext(filename); ext != "" { header.Header.Set("Content-Extension", ext) } return &FileHeader{FileHeader: header} } // checkFilePermission checks if user has permission to access the file // readable: true for read permission, false for write permission func checkFilePermission(p *process.Process, fileInfo *File, readable bool) error { auth := p.Authorized // No auth info - allow access (for non-authenticated operations) if auth == nil { return nil } // No constraints - allow access if !auth.Constraints.TeamOnly && !auth.Constraints.OwnerOnly { return nil } // Public files are readable by everyone if readable && fileInfo.Public { return nil } // Combined Team and Owner permission validation if auth.Constraints.TeamOnly && auth.Constraints.OwnerOnly { if fileInfo.YaoCreatedBy == auth.UserID && fileInfo.YaoTeamID == auth.TeamID { return nil } } // Owner only permission validation if auth.Constraints.OwnerOnly { if fileInfo.YaoCreatedBy != "" && fileInfo.YaoCreatedBy == auth.UserID { return nil } } // Team only permission validation if auth.Constraints.TeamOnly { switch fileInfo.Share { case "team": if fileInfo.YaoTeamID == auth.TeamID { return nil } case "private": if fileInfo.YaoCreatedBy == auth.UserID { return nil } } } return fmt.Errorf("forbidden: no permission to access file") } // buildPermissionWheres builds where clauses for permission filtering func buildPermissionWheres(p *process.Process) []model.QueryWhere { auth := p.Authorized if auth == nil { return nil } // No constraints - no additional filtering needed if !auth.Constraints.TeamOnly && !auth.Constraints.OwnerOnly { return nil } var wheres []model.QueryWhere // 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 auth.Constraints.TeamOnly && auth.TeamID != "" { wheres = append(wheres, model.QueryWhere{ Wheres: []model.QueryWhere{ {Column: "public", Value: true, Method: "orwhere"}, {Wheres: []model.QueryWhere{ {Column: "__yao_team_id", Value: auth.TeamID}, {Wheres: []model.QueryWhere{ {Column: "__yao_created_by", Value: auth.UserID}, {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 auth.Constraints.OwnerOnly && auth.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: auth.UserID}, }, Method: "orwhere"}, }, }) return wheres } return wheres } // processZip packages multiple attachment files into a single zip archive // and uploads it back to the same uploader storage. // // Args: // - uploaderID: string - the uploader/manager ID (e.g. "__yao.attachment") // - fileIDs: []string - list of file IDs to package // - zipFilename: string - output zip filename (e.g. "my-notes.zip") // - option: map (optional) - upload options (groups, gzip, public, share) // // Returns: *File - uploaded zip file info (file_id, filename, bytes, etc.) // // Permission: checks read permission on each source file via p.Authorized. // The uploaded zip inherits permission fields (created_by, team_id, tenant_id) // from the process context automatically via createUploadOption. // // Example: // // Process("attachment.Zip", "__yao.attachment", ["abc123", "def456"], "archive.zip") // Process("attachment.Zip", "__yao.attachment", ["abc123"], "archive.zip", {"public": true}) func processZip(p *process.Process) interface{} { p.ValidateArgNums(3) uploaderID := p.ArgsString(0) fileIDs := toStringSlice(p.Args[1]) zipFilename := p.ArgsString(2) if len(fileIDs) == 0 { return fmt.Errorf("attachment.Zip: fileIDs is empty") } if zipFilename == "" { zipFilename = "archive.zip" } // Ensure .zip extension if !strings.HasSuffix(strings.ToLower(zipFilename), ".zip") { zipFilename += ".zip" } // Get manager manager, exists := Managers[uploaderID] if !exists { return fmt.Errorf("uploader not found: %s", uploaderID) } ctx := context.Background() // Build zip in memory buf := new(bytes.Buffer) zipWriter := zip.NewWriter(buf) usedNames := map[string]int{} // for deduplication for _, fid := range fileIDs { // Get file info fileInfo, err := manager.Info(ctx, fid) if err != nil { return fmt.Errorf("attachment.Zip: failed to get file info for %s: %v", fid, err) } // Check read permission if err := checkFilePermission(p, fileInfo, true); err != nil { return fmt.Errorf("attachment.Zip: permission denied for file %s: %v", fid, err) } // Read file content data, err := manager.Read(ctx, fid) if err != nil { return fmt.Errorf("attachment.Zip: failed to read file %s: %v", fid, err) } // Deduplicate filename name := dedupFilename(fileInfo.Filename, usedNames) // Write to zip w, err := zipWriter.Create(name) if err != nil { return fmt.Errorf("attachment.Zip: failed to create zip entry %s: %v", name, err) } if _, err := w.Write(data); err != nil { return fmt.Errorf("attachment.Zip: failed to write zip entry %s: %v", name, err) } } if err := zipWriter.Close(); err != nil { return fmt.Errorf("attachment.Zip: failed to close zip writer: %v", err) } // Upload the zip file header := createFileHeader(zipFilename, "application/zip", int64(buf.Len())) option := createUploadOption(p, zipFilename) file, err := manager.Upload(ctx, header, buf, option) if err != nil { return fmt.Errorf("attachment.Zip: failed to upload zip: %v", err) } return file } // toStringSlice converts []interface{} to []string func toStringSlice(v interface{}) []string { switch val := v.(type) { case []string: return val case []interface{}: result := make([]string, 0, len(val)) for _, item := range val { if s, ok := item.(string); ok { result = append(result, s) } } return result default: return nil } } // dedupFilename ensures unique filenames within a zip archive. // If "photo.png" already exists, returns "photo(1).png", "photo(2).png", etc. func dedupFilename(name string, used map[string]int) string { if name == "" { name = "file" } lower := strings.ToLower(name) count, exists := used[lower] if !exists { used[lower] = 1 return name } // Generate deduplicated name ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) used[lower] = count + 1 return fmt.Sprintf("%s(%d)%s", base, count, ext) }