From 070ff59225a5d47f33ba1e39a2fe977dd11aa13f Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Jul 2025 19:25:41 +0800 Subject: [PATCH] Enhance attachment management with file storage improvements - Refactored the attachment manager to support file uploads with a new storage path and improved metadata handling. - Implemented chunked uploads and direct content retrieval, enhancing performance and flexibility. - Updated the file management API to include comprehensive operations for file uploads, downloads, and metadata management. - Added support for multiple storage backends, including local and S3, with improved error handling and validation. - Enhanced test coverage for file operations, ensuring reliability and consistency across different storage implementations. --- attachment/local/storage.go | 63 +- attachment/local/storage_test.go | 5 + attachment/manager.go | 544 ++++++++++++++-- attachment/manager_test.go | 501 ++++++++++++++- attachment/s3/storage.go | 67 +- attachment/s3/storage_test.go | 5 + attachment/types.go | 83 ++- data/bindata.go | 206 +++--- openapi/README.md | 64 ++ openapi/file/README.md | 525 ++++++++++++++++ openapi/file/file.go | 470 ++++++++++++++ openapi/openapi.go | 4 + openapi/tests/file/file_test.go | 1010 ++++++++++++++++++++++++++++++ yao/models/attachment.mod.yao | 103 +-- 14 files changed, 3390 insertions(+), 260 deletions(-) create mode 100644 openapi/file/README.md create mode 100644 openapi/file/file.go create mode 100644 openapi/tests/file/file_test.go diff --git a/attachment/local/storage.go b/attachment/local/storage.go index 25ba1756..0ba0ce1c 100644 --- a/attachment/local/storage.go +++ b/attachment/local/storage.go @@ -58,17 +58,17 @@ func New(options map[string]interface{}) (*Storage, error) { } // Upload upload file to local storage -func (storage *Storage) Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error) { - path := filepath.Join(storage.Path, fileID) +func (storage *Storage) Upload(ctx context.Context, path string, reader io.Reader, contentType string) (string, error) { + fullPath := filepath.Join(storage.Path, path) // Create directory if not exists - dir := filepath.Dir(path) + dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { return "", err } // Create and write file - file, err := os.Create(path) + file, err := os.Create(fullPath) if err != nil { return "", err } @@ -79,13 +79,13 @@ func (storage *Storage) Upload(ctx context.Context, fileID string, reader io.Rea return "", err } - return fileID, nil + return path, nil } // UploadChunk uploads a chunk of a file -func (storage *Storage) UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error { +func (storage *Storage) UploadChunk(ctx context.Context, path string, chunkIndex int, reader io.Reader, contentType string) error { // Create chunks directory - chunksDir := filepath.Join(storage.Path, ".chunks", fileID) + chunksDir := filepath.Join(storage.Path, ".chunks", path) if err := os.MkdirAll(chunksDir, 0755); err != nil { return err } @@ -103,9 +103,9 @@ func (storage *Storage) UploadChunk(ctx context.Context, fileID string, chunkInd } // MergeChunks merges all chunks into the final file -func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChunks int) error { - chunksDir := filepath.Join(storage.Path, ".chunks", fileID) - finalPath := filepath.Join(storage.Path, fileID) +func (storage *Storage) MergeChunks(ctx context.Context, path string, totalChunks int) error { + chunksDir := filepath.Join(storage.Path, ".chunks", path) + finalPath := filepath.Join(storage.Path, path) // Create directory for final file dir := filepath.Dir(finalPath) @@ -141,8 +141,8 @@ func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChu } // Reader read file from local storage -func (storage *Storage) Reader(ctx context.Context, fileID string) (io.ReadCloser, error) { - fullpath := filepath.Join(storage.Path, fileID) +func (storage *Storage) Reader(ctx context.Context, path string) (io.ReadCloser, error) { + fullpath := filepath.Join(storage.Path, path) reader, err := os.Open(fullpath) if err != nil { @@ -150,7 +150,7 @@ func (storage *Storage) Reader(ctx context.Context, fileID string) (io.ReadClose } // If the file is a gzip file, decompress it - if strings.HasSuffix(fileID, ".gz") { + if strings.HasSuffix(path, ".gz") { reader, err := gzip.NewReader(reader) if err != nil { return nil, err @@ -162,16 +162,16 @@ func (storage *Storage) Reader(ctx context.Context, fileID string) (io.ReadClose } // Download download file from local storage -func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadCloser, string, error) { - path := filepath.Join(storage.Path, fileID) - reader, err := os.Open(path) +func (storage *Storage) Download(ctx context.Context, path string) (io.ReadCloser, string, error) { + fullPath := filepath.Join(storage.Path, path) + reader, err := os.Open(fullPath) if err != nil { return nil, "", err } // Try to detect content type from file extension contentType := "application/octet-stream" - ext := filepath.Ext(strings.TrimSuffix(fileID, ".gz")) + ext := filepath.Ext(strings.TrimSuffix(path, ".gz")) switch strings.ToLower(ext) { case ".txt": contentType = "text/plain" @@ -207,7 +207,7 @@ func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadClo } // If the file is a gzip file, decompress it - if strings.HasSuffix(fileID, ".gz") { + if strings.HasSuffix(path, ".gz") { reader, err := gzip.NewReader(reader) if err != nil { return nil, "", err @@ -219,26 +219,37 @@ func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadClo } // URL get file url -func (storage *Storage) URL(ctx context.Context, fileID string) string { +func (storage *Storage) URL(ctx context.Context, path string) string { if storage.PreviewURL != nil { - return storage.PreviewURL(fileID) + return storage.PreviewURL(path) } if storage.BaseURL != "" { - return fmt.Sprintf("%s/%s", strings.TrimRight(storage.BaseURL, "/"), fileID) + return fmt.Sprintf("%s/%s", strings.TrimRight(storage.BaseURL, "/"), path) } - return fmt.Sprintf("%s/%s", storage.Path, fileID) + return fmt.Sprintf("%s/%s", storage.Path, path) +} + +// GetContent gets file content as bytes +func (storage *Storage) GetContent(ctx context.Context, path string) ([]byte, error) { + reader, err := storage.Reader(ctx, path) + if err != nil { + return nil, err + } + defer reader.Close() + + return io.ReadAll(reader) } // Exists checks if a file exists -func (storage *Storage) Exists(ctx context.Context, fileID string) bool { - fullpath := filepath.Join(storage.Path, fileID) +func (storage *Storage) Exists(ctx context.Context, path string) bool { + fullpath := filepath.Join(storage.Path, path) _, err := os.Stat(fullpath) return err == nil } // Delete deletes a file -func (storage *Storage) Delete(ctx context.Context, fileID string) error { - fullpath := filepath.Join(storage.Path, fileID) +func (storage *Storage) Delete(ctx context.Context, path string) error { + fullpath := filepath.Join(storage.Path, path) return os.Remove(fullpath) } diff --git a/attachment/local/storage_test.go b/attachment/local/storage_test.go index 8e1956b0..793164ee 100644 --- a/attachment/local/storage_test.go +++ b/attachment/local/storage_test.go @@ -210,6 +210,11 @@ func TestLocalStorage(t *testing.T) { assert.NoError(t, err) assert.Equal(t, content, data) + // Get file content directly + directContent, err := storage.GetContent(context.Background(), fileID) + assert.NoError(t, err) + assert.Equal(t, content, directContent) + // Delete file err = storage.Delete(context.Background(), fileID) assert.NoError(t, err) diff --git a/attachment/manager.go b/attachment/manager.go index cbe5503f..fbe2585d 100644 --- a/attachment/manager.go +++ b/attachment/manager.go @@ -3,8 +3,9 @@ package attachment import ( "bytes" "context" - "crypto/sha256" + "crypto/md5" "encoding/base64" + "encoding/hex" "fmt" "io" "mime" @@ -13,16 +14,21 @@ import ( "net/textproto" "os" "path/filepath" + "reflect" "strconv" "strings" "sync" "time" + "github.com/yaoapp/gou/model" "github.com/yaoapp/yao/attachment/local" "github.com/yaoapp/yao/attachment/s3" "github.com/yaoapp/yao/config" ) +// Ensure Manager implements FileManager interface +var _ FileManager = (*Manager)(nil) + // Managers the managers var Managers = map[string]*Manager{} var uploadChunks = sync.Map{} @@ -74,6 +80,9 @@ func Register(name string, driver string, option ManagerOption) (*Manager, error return nil, err } + // Set the manager name + manager.Name = name + // Register the manager Managers[name] = manager return manager, nil @@ -259,8 +268,8 @@ func (manager Manager) Upload(ctx context.Context, fileheader *FileHeader, reade } - // Upload chunk - err = manager.storage.UploadChunk(ctx, file.ID, chunkIndex, reader, file.ContentType) + // Upload chunk using the storage path + err = manager.storage.UploadChunk(ctx, file.Path, chunkIndex, reader, file.ContentType) if err != nil { return nil, err } @@ -271,7 +280,7 @@ func (manager Manager) Upload(ctx context.Context, fileheader *FileHeader, reade // If this is the last chunk, merge all chunks if fileheader.Complete() { - err = manager.storage.MergeChunks(ctx, file.ID, int(chunkdata.TotalChunks)) + err = manager.storage.MergeChunks(ctx, file.Path, int(chunkdata.TotalChunks)) if err != nil { return nil, err } @@ -290,6 +299,12 @@ func (manager Manager) Upload(ctx context.Context, fileheader *FileHeader, reade // Fix the file size file.Bytes = int(chunkdata.Total) file.Status = "uploaded" + + // Save file information to database when chunked upload is complete + err = manager.saveFileToDatabase(ctx, file, file.Path, option) + if err != nil { + return nil, fmt.Errorf("failed to save chunked file to database: %w", err) + } } return file, nil @@ -304,6 +319,7 @@ func (manager Manager) Upload(ctx context.Context, fileheader *FileHeader, reade if err != nil { return nil, fmt.Errorf("failed to gzip file: %w", err) } + finalReader = bytes.NewReader(compressed) } @@ -344,22 +360,33 @@ func (manager Manager) Upload(ctx context.Context, fileheader *FileHeader, reade } } - // Upload the file to storage - id, err := manager.storage.Upload(ctx, file.ID, finalReader, file.ContentType) + // Upload the file to storage using the generated storage path + actualStoragePath, err := manager.storage.Upload(ctx, file.Path, finalReader, file.ContentType) if err != nil { return nil, err } - // Update the file ID - file.ID = id + // Update the actual storage path if storage returns a different path + if actualStoragePath != "" && actualStoragePath != file.Path { + file.Path = actualStoragePath + } + + // Update the file status file.Status = "uploaded" + + // Save file information to database + err = manager.saveFileToDatabase(ctx, file, file.Path, option) + if err != nil { + return nil, fmt.Errorf("failed to save file to database: %w", err) + } + return file, nil } // compressStoredImage compresses an already stored image func (manager Manager) compressStoredImage(ctx context.Context, file *File, option UploadOption) error { - // Download the stored file - reader, err := manager.storage.Reader(ctx, file.ID) + // Download the stored file using storage path + reader, err := manager.storage.Reader(ctx, file.Path) if err != nil { return err } @@ -376,19 +403,25 @@ func (manager Manager) compressStoredImage(ctx context.Context, file *File, opti return err } - // Re-upload the compressed image - _, err = manager.storage.Upload(ctx, file.ID, bytes.NewReader(compressed), file.ContentType) + // Re-upload the compressed image using storage path + _, err = manager.storage.Upload(ctx, file.Path, bytes.NewReader(compressed), file.ContentType) return err } // Download downloads a file func (manager Manager) Download(ctx context.Context, fileID string) (*FileResponse, error) { - reader, contentType, err := manager.storage.Download(ctx, fileID) + // Get real storage path from database + storagePath, err := manager.getStoragePathFromDatabase(ctx, fileID) if err != nil { return nil, err } - extension := filepath.Ext(fileID) + reader, contentType, err := manager.storage.Download(ctx, storagePath) + if err != nil { + return nil, err + } + + extension := filepath.Ext(storagePath) if extension == "" { // Try to get extension from content type extensions, err := mime.ExtensionsByType(contentType) @@ -406,13 +439,27 @@ func (manager Manager) Download(ctx context.Context, fileID string) (*FileRespon // Read reads a file and returns the content as bytes func (manager Manager) Read(ctx context.Context, fileID string) ([]byte, error) { - reader, err := manager.storage.Reader(ctx, fileID) + // Get file info from database to check if it's gzipped + file, err := manager.getFileFromDatabase(ctx, fileID) + if err != nil { + return nil, err + } + + reader, err := manager.storage.Reader(ctx, file.Path) if err != nil { return nil, err } defer reader.Close() - return io.ReadAll(reader) + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + // Storage layer already handles gzip decompression for .gz files + // No need to decompress again at Manager level + + return data, nil } // ReadBase64 reads a file and returns the content as base64 encoded string @@ -425,6 +472,192 @@ func (manager Manager) ReadBase64(ctx context.Context, fileID string) (string, e return base64.StdEncoding.EncodeToString(data), nil } +// Info retrieves complete file information from database by file ID +func (manager Manager) Info(ctx context.Context, fileID string) (*File, error) { + return manager.getFileFromDatabase(ctx, fileID) +} + +// List retrieves files from database with pagination and filtering +func (manager Manager) List(ctx context.Context, option ListOption) (*ListResult, error) { + m := model.Select("__yao.attachment") + + // Set default values + page := option.Page + if page <= 0 { + page = 1 + } + + pageSize := option.PageSize + if pageSize <= 0 { + pageSize = 20 + } + + // Build query parameters + queryParam := model.QueryParam{} + + // 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 + } + } + + // Add filters + if len(option.Filters) > 0 { + queryParam.Wheres = make([]model.QueryWhere, 0, len(option.Filters)) + for field, value := range option.Filters { + where := model.QueryWhere{ + Column: field, + Value: value, + } + + // Handle special operators for wildcard matching + if strValue, ok := value.(string); ok { + if strings.Contains(strValue, "*") { + // Wildcard matching for LIKE queries + where.OP = "like" + where.Value = strings.ReplaceAll(strValue, "*", "%") + } + } + + queryParam.Wheres = append(queryParam.Wheres, where) + } + } + + // Add ordering + if option.OrderBy != "" { + // Parse order by string like "created_at desc" or "name asc" + parts := strings.Fields(option.OrderBy) + if len(parts) >= 1 { + orderField := parts[0] + orderDirection := "asc" + if len(parts) >= 2 { + orderDirection = strings.ToLower(parts[1]) + } + + queryParam.Orders = []model.QueryOrder{ + { + Column: orderField, + Option: orderDirection, + }, + } + } + } else { + // Default order by created_at desc + queryParam.Orders = []model.QueryOrder{ + { + Column: "created_at", + Option: "desc", + }, + } + } + + // Use model's built-in Paginate method + result, err := m.Paginate(queryParam, page, pageSize) + if err != nil { + return nil, fmt.Errorf("failed to paginate files: %w", err) + } + + // Extract pagination info from result + total := int64(0) + if totalInterface, ok := result["total"]; ok { + if totalInt, ok := totalInterface.(int); ok { + total = int64(totalInt) + } else if totalInt64, ok := totalInterface.(int64); ok { + total = totalInt64 + } + } + + // Extract data from result - handle maps.MapStrAny type + var records []map[string]interface{} + if dataInterface, ok := result["data"]; ok { + // The data is of type []maps.MapStrAny, need to convert + if dataSlice, ok := dataInterface.([]interface{}); ok { + records = make([]map[string]interface{}, len(dataSlice)) + for i, item := range dataSlice { + if record, ok := item.(map[string]interface{}); ok { + records[i] = record + } + } + } else { + // Try to handle it as the actual type returned by gou using reflection + dataValue := reflect.ValueOf(dataInterface) + if dataValue.Kind() == reflect.Slice { + length := dataValue.Len() + records = make([]map[string]interface{}, length) + for i := 0; i < length; i++ { + item := dataValue.Index(i).Interface() + // Convert the item to map[string]interface{} using reflection + if itemValue := reflect.ValueOf(item); itemValue.Kind() == reflect.Map { + record := make(map[string]interface{}) + for _, key := range itemValue.MapKeys() { + if keyStr := key.String(); keyStr != "" { + record[keyStr] = itemValue.MapIndex(key).Interface() + } + } + records[i] = record + } + } + } + } + } + + // Convert records to File structs + files := make([]*File, 0, len(records)) + for _, record := range records { + file := &File{} + + // Map required fields + if fileID, ok := record["file_id"].(string); ok { + file.ID = fileID + } + if name, ok := record["name"].(string); ok { + file.Filename = name + } + if contentType, ok := record["content_type"].(string); ok { + file.ContentType = contentType + } + if status, ok := record["status"].(string); ok { + file.Status = status + } + + // Map optional fields + if userPath, ok := record["user_path"].(string); ok { + file.UserPath = userPath + } + if path, ok := record["path"].(string); ok { + file.Path = path + } + if bytes, ok := record["bytes"].(int64); ok { + file.Bytes = int(bytes) + } else if bytesInt, ok := record["bytes"].(int); ok { + file.Bytes = bytesInt + } + if createdAt, ok := record["created_at"].(int64); ok { + file.CreatedAt = int(createdAt) + } else if createdAtInt, ok := record["created_at"].(int); ok { + file.CreatedAt = createdAtInt + } else { + // Fallback to current time if not available + file.CreatedAt = int(time.Now().Unix()) + } + + files = append(files, file) + } + + // Calculate total pages + totalPages := int((total + int64(pageSize) - 1) / int64(pageSize)) + + return &ListResult{ + Files: files, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + // validate validates the file and option func (manager Manager) makeFile(file *FileHeader, option UploadOption) (*File, error) { @@ -438,8 +671,10 @@ func (manager Manager) makeFile(file *FileHeader, option UploadOption) (*File, e // Use original filename if provided, otherwise use the file header filename filename := file.Filename - if option.OriginalFilename != "" { - filename = option.OriginalFilename + userPath := option.OriginalFilename + if userPath != "" { + // If user provided a path, extract just the filename for the filename field + filename = filepath.Base(userPath) } extension := filepath.Ext(filename) @@ -482,15 +717,23 @@ func (manager Manager) makeFile(file *FileHeader, option UploadOption) (*File, e return nil, fmt.Errorf("%s type %s is not allowed", filename, contentType) } - // Generate file ID - id, err := manager.generateFileID(file, extension, option) + // Generate file ID and storage path using the new approach + id, storagePath, err := manager.generateFilePaths(file, extension, option) if err != nil { return nil, err } + // Set the path: use userPath if provided, otherwise use filename + filePath := userPath + if filePath == "" { + filePath = filename + } + return &File{ ID: id, - Filename: filename, // Use the correct filename (original or from header) + UserPath: userPath, // Keep user's original input exactly as provided + Path: storagePath, // Complete storage path: Groups + filename + Filename: filename, // Use just the filename (extracted from path or header) ContentType: contentType, Bytes: int(file.Size), CreatedAt: int(time.Now().Unix()), @@ -522,41 +765,86 @@ func (manager Manager) allowed(contentType string, extension string) bool { return false } -// generateFileID generates a file ID with proper namespace -func (manager Manager) generateFileID(file *FileHeader, extension string, option UploadOption) (string, error) { +// generateFileID generates file ID and storage path based on Groups and filename +func (manager Manager) generateFilePaths(file *FileHeader, extension string, option UploadOption) (fileID string, storagePath string, err error) { - filename := file.Fingerprint() - - // If the fingerprint is not set, use the filename - if filename == "" { - filename = file.Filename - } - - // Use original filename if provided for better file identification - if option.OriginalFilename != "" { - filename = option.OriginalFilename - } - - if file.IsChunk() { + // 1. Get the filename + var filename string + if file.Fingerprint() != "" { + filename = file.Fingerprint() + } else if file.IsChunk() { filename = file.UID() + } else { + // Generate unique filename to avoid conflicts + var originalName string + if option.OriginalFilename != "" { + originalName = filepath.Base(option.OriginalFilename) + } else { + originalName = file.Filename + } + + // Extract extension from original filename + ext := filepath.Ext(originalName) + if ext == "" && extension != "" { + ext = extension + } + + // Generate unique filename: MD5 hash of original name + timestamp + extension + nameHash := generateID(originalName + fmt.Sprintf("%d", time.Now().UnixNano())) + filename = nameHash[:16] + ext // Use first 16 chars of hash + extension } - hash := fmt.Sprintf("%x", sha256.Sum256([]byte(filename)))[:8] - date := time.Now().Format("20060102") - path := filepath.Join("attachments", date) + // 2. Build complete storage path: Groups + filename + pathParts := []string{} - // Build multi-level group path - for _, group := range option.Groups { - if group != "" { - path = filepath.Join(path, group) + // Add groups to path + if len(option.Groups) > 0 { + pathParts = append(pathParts, option.Groups...) + } + + // Add filename + pathParts = append(pathParts, filename) + + // Join to create complete storage path + storagePath = strings.Join(pathParts, "/") + + // 3. Validate the storage path + if !isValidPath(storagePath) { + return "", "", fmt.Errorf("invalid storage path: %s", storagePath) + } + + // 4. Generate ID as alias of the storage path (for security) + fileID = generateID(storagePath) + + // 5. Add gzip extension to storage path if needed (not to fileID) + if option.Gzip { + storagePath = storagePath + ".gz" + } + + return fileID, storagePath, nil +} + +// generateID generates a URL-safe ID based on the storage path +func generateID(storagePath string) string { + hash := md5.Sum([]byte(storagePath)) + return hex.EncodeToString(hash[:]) +} + +// isValidPath checks if a file path is valid +func isValidPath(path string) bool { + if path == "" { + return false + } + + // Check for invalid characters that could cause issues + invalidChars := []string{"../", "..\\", "\\", "//"} + for _, invalid := range invalidChars { + if strings.Contains(path, invalid) { + return false } } - id := filepath.Join(path, hash[:2], hash[2:4], hash) + extension - if option.Gzip { - id = id + ".gz" - } - return id, nil + return true } // getSize converts the size to bytes @@ -590,3 +878,163 @@ func getSize(size string) (int64, error) { return 0, fmt.Errorf("invalid size: %s", size) } + +// Exists checks if a file exists in storage +func (manager Manager) Exists(ctx context.Context, fileID string) bool { + // Check if file exists in database first + storagePath, err := manager.getStoragePathFromDatabase(ctx, fileID) + if err != nil { + return false + } + + // Then check if it exists in storage + return manager.storage.Exists(ctx, storagePath) +} + +// Delete deletes a file from storage +func (manager Manager) Delete(ctx context.Context, fileID string) error { + // Get real storage path from database + storagePath, err := manager.getStoragePathFromDatabase(ctx, fileID) + if err != nil { + return err + } + + // Delete from storage + err = manager.storage.Delete(ctx, storagePath) + if err != nil { + return err + } + + // Delete from database + m := model.Select("__yao.attachment") + _, err = m.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "file_id", Value: fileID}, + }, + }) + + if err != nil { + return fmt.Errorf("failed to delete from database: %w", err) + } + + return nil +} + +// saveFileToDatabase saves file information to the database +func (manager Manager) saveFileToDatabase(ctx context.Context, file *File, storagePath string, option UploadOption) error { + + m := model.Select("__yao.attachment") + + // Prepare data for database + data := map[string]interface{}{ + "file_id": file.ID, + "uploader": manager.Name, + "content_type": file.ContentType, + "name": file.Filename, + "user_path": option.OriginalFilename, + "path": storagePath, + "bytes": int64(file.Bytes), + "status": file.Status, + "gzip": option.Gzip, + "groups": option.Groups, + "client_id": option.ClientID, + "openid": option.OpenID, + } + + // Check if record exists first + records, err := m.Get(model.QueryParam{ + Select: []interface{}{"file_id"}, + Wheres: []model.QueryWhere{ + {Column: "file_id", Value: file.ID}, + }, + }) + + if err != nil { + return fmt.Errorf("failed to check existing record: %w", err) + } + + if len(records) > 0 { + // Update existing record + _, err = m.UpdateWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "file_id", Value: file.ID}, + }, + }, data) + } else { + // Create new record + _, err = m.Create(data) + } + + return err +} + +// getFileFromDatabase retrieves file information from database by file_id +func (manager Manager) getFileFromDatabase(ctx context.Context, fileID string) (*File, error) { + m := model.Select("__yao.attachment") + + records, err := m.Get(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "file_id", Value: fileID}, + }, + }) + + if err != nil { + return nil, fmt.Errorf("failed to query file: %w", err) + } + + if len(records) == 0 { + return nil, fmt.Errorf("file not found") + } + + record := records[0] + + // Convert database record to File struct + file := &File{ + ID: record["file_id"].(string), + Filename: record["name"].(string), + ContentType: record["content_type"].(string), + Status: record["status"].(string), + CreatedAt: int(time.Now().Unix()), // TODO: get from database + } + + // Handle optional fields + if userPath, ok := record["user_path"].(string); ok { + file.UserPath = userPath + } + + if path, ok := record["path"].(string); ok { + file.Path = path + } + + if bytes, ok := record["bytes"].(int64); ok { + file.Bytes = int(bytes) + } + + return file, nil +} + +// getStoragePathFromDatabase retrieves the real storage path for a file_id +func (manager Manager) getStoragePathFromDatabase(ctx context.Context, fileID string) (string, error) { + m := model.Select("__yao.attachment") + + records, err := m.Get(model.QueryParam{ + Select: []interface{}{"path"}, + Wheres: []model.QueryWhere{ + {Column: "file_id", Value: fileID}, + }, + }) + + if err != nil { + return "", fmt.Errorf("failed to query database: %w", err) + } + + if len(records) == 0 { + return "", fmt.Errorf("file not found: %s", fileID) + } + + if path, ok := records[0]["path"].(string); ok && path != "" { + return path, nil + } + + return "", fmt.Errorf("invalid storage path for file ID: %s", fileID) +} diff --git a/attachment/manager_test.go b/attachment/manager_test.go index 13f14151..21ebc129 100644 --- a/attachment/manager_test.go +++ b/attachment/manager_test.go @@ -6,11 +6,26 @@ import ( "encoding/base64" "fmt" "mime/multipart" + "os" "strings" "testing" + "time" + + "github.com/yaoapp/gou/model" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" ) +func TestMain(m *testing.M) { + // Run tests + code := m.Run() + os.Exit(code) +} + func TestManagerUpload(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + // Create a local storage manager manager, err := New(ManagerOption{ Driver: "local", @@ -173,6 +188,9 @@ func TestManagerUpload(t *testing.T) { } func TestManagerMultiLevelGroups(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + // Create a local storage manager manager, err := New(ManagerOption{ Driver: "local", @@ -210,13 +228,16 @@ func TestManagerMultiLevelGroups(t *testing.T) { t.Fatalf("Failed to upload file with multi-level groups: %v", err) } - // Verify the file ID contains the nested structure - if !strings.Contains(file.ID, "users") || - !strings.Contains(file.ID, "user123") || - !strings.Contains(file.ID, "chats") || - !strings.Contains(file.ID, "chat456") || - !strings.Contains(file.ID, "documents") { - t.Errorf("File ID should contain all group levels: %s", file.ID) + // File ID should be 32 character hex (MD5 hash) + if len(file.ID) != 32 { + t.Errorf("File ID should be 32 characters: %s (length %d)", file.ID, len(file.ID)) + } + + // Check that it's all lowercase hex + for _, r := range file.ID { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + t.Errorf("File ID contains non-hex character: %c", r) + } } // Test download @@ -253,8 +274,16 @@ func TestManagerMultiLevelGroups(t *testing.T) { t.Fatalf("Failed to upload file with single group: %v", err) } - if !strings.Contains(file.ID, "knowledge") { - t.Errorf("File ID should contain group: %s", file.ID) + // File ID should be 32 character hex (MD5 hash) + if len(file.ID) != 32 { + t.Errorf("File ID should be 32 characters: %s (length %d)", file.ID, len(file.ID)) + } + + // Check that it's all lowercase hex + for _, r := range file.ID { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + t.Errorf("File ID contains non-hex character: %c", r) + } } }) @@ -289,6 +318,9 @@ func TestManagerMultiLevelGroups(t *testing.T) { } func TestManagerValidation(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + manager, err := New(ManagerOption{ Driver: "local", MaxSize: "1K", // Very small max size for testing @@ -345,3 +377,454 @@ func TestManagerValidation(t *testing.T) { } }) } + +func TestManagerName(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + // Create a manager with a specific name + managerName := "test-manager" + manager, err := RegisterDefault(managerName) + if err != nil { + t.Fatalf("Failed to register manager: %v", err) + } + + // Verify the manager name is set correctly + if manager.Name != managerName { + t.Errorf("Expected manager name '%s', got '%s'", managerName, manager.Name) + } + + // Upload a file to verify the manager name is saved to database + content := "Test file content" + reader := strings.NewReader(content) + + fileHeader := &FileHeader{ + FileHeader: &multipart.FileHeader{ + Filename: "test-manager-name.txt", + Size: int64(len(content)), + Header: make(map[string][]string), + }, + } + fileHeader.Header.Set("Content-Type", "text/plain") + + option := UploadOption{ + Groups: []string{"test"}, + } + + file, err := manager.Upload(context.Background(), fileHeader, reader, option) + if err != nil { + t.Fatalf("Failed to upload file: %v", err) + } + + // Query database directly to verify manager name is stored + m := model.Select("__yao.attachment") + records, err := m.Get(model.QueryParam{ + Select: []interface{}{"uploader"}, + 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") + } + + storedManagerName, ok := records[0]["uploader"].(string) + if !ok { + t.Fatal("Uploader field is not a string") + } + + if storedManagerName != managerName { + t.Errorf("Expected stored uploader name '%s', got '%s'", managerName, storedManagerName) + } +} + +func TestUniqueFilenameGeneration(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager, err := RegisterDefault("test") + if err != nil { + t.Fatalf("Failed to register manager: %v", err) + } + + // Upload two files with the same filename + content1 := "First file content" + content2 := "Second file content" + + // First file + reader1 := strings.NewReader(content1) + fileHeader1 := &FileHeader{ + FileHeader: &multipart.FileHeader{ + Filename: "duplicate.txt", // Same filename + Size: int64(len(content1)), + Header: make(map[string][]string), + }, + } + fileHeader1.Header.Set("Content-Type", "text/plain") + + // Second file + reader2 := strings.NewReader(content2) + fileHeader2 := &FileHeader{ + FileHeader: &multipart.FileHeader{ + Filename: "duplicate.txt", // Same filename + Size: int64(len(content2)), + Header: make(map[string][]string), + }, + } + fileHeader2.Header.Set("Content-Type", "text/plain") + + option := UploadOption{ + Groups: []string{"test"}, + } + + // Upload first file + file1, err := manager.Upload(context.Background(), fileHeader1, reader1, option) + if err != nil { + t.Fatalf("Failed to upload first file: %v", err) + } + + // Sleep a bit to ensure different timestamps + time.Sleep(time.Millisecond) + + // Upload second file + file2, err := manager.Upload(context.Background(), fileHeader2, reader2, option) + if err != nil { + t.Fatalf("Failed to upload second file: %v", err) + } + + // Verify files have different IDs + if file1.ID == file2.ID { + t.Error("Files with same original name should have different IDs") + } + + // Verify files have different storage paths + if file1.Path == file2.Path { + t.Error("Files with same original name should have different storage paths") + } + + // Verify both files can be read independently + data1, err := manager.Read(context.Background(), file1.ID) + if err != nil { + t.Fatalf("Failed to read first file: %v", err) + } + + data2, err := manager.Read(context.Background(), file2.ID) + if err != nil { + t.Fatalf("Failed to read second file: %v", err) + } + + if string(data1) != content1 { + t.Errorf("First file content mismatch. Expected: %s, Got: %s", content1, string(data1)) + } + + if string(data2) != content2 { + t.Errorf("Second file content mismatch. Expected: %s, Got: %s", content2, string(data2)) + } + + t.Logf("File 1 - ID: %s, Path: %s", file1.ID, file1.Path) + t.Logf("File 2 - ID: %s, Path: %s", file2.ID, file2.Path) +} + +func TestInfo(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager, err := RegisterDefault("test") + if err != nil { + t.Fatalf("Failed to register manager: %v", err) + } + + // Upload a test file + content := "Test file for info retrieval" + reader := strings.NewReader(content) + + fileHeader := &FileHeader{ + FileHeader: &multipart.FileHeader{ + Filename: "info-test.txt", + Size: int64(len(content)), + Header: make(map[string][]string), + }, + } + fileHeader.Header.Set("Content-Type", "text/plain") + + option := UploadOption{ + Groups: []string{"info", "test"}, + OriginalFilename: "original-info-test.txt", + ClientID: "test-client-123", + OpenID: "test-openid-456", + Gzip: false, + } + + uploadedFile, err := manager.Upload(context.Background(), fileHeader, reader, option) + if err != nil { + t.Fatalf("Failed to upload file: %v", err) + } + + // Test the Info method + fileInfo, err := manager.Info(context.Background(), uploadedFile.ID) + if err != nil { + t.Fatalf("Failed to get file info: %v", err) + } + + // Verify file information + if fileInfo.ID != uploadedFile.ID { + t.Errorf("Expected file ID %s, got %s", uploadedFile.ID, fileInfo.ID) + } + + if fileInfo.Filename != uploadedFile.Filename { + t.Errorf("Expected filename %s, got %s", uploadedFile.Filename, fileInfo.Filename) + } + + if fileInfo.ContentType != "text/plain" { + t.Errorf("Expected content type 'text/plain', got %s", fileInfo.ContentType) + } + + if fileInfo.Status != "uploaded" { + t.Errorf("Expected status 'uploaded', got %s", fileInfo.Status) + } + + if fileInfo.UserPath != option.OriginalFilename { + t.Errorf("Expected user path %s, got %s", option.OriginalFilename, fileInfo.UserPath) + } + + if fileInfo.Path != uploadedFile.Path { + t.Errorf("Expected path %s, got %s", uploadedFile.Path, fileInfo.Path) + } + + // Test with non-existent file ID + _, err = manager.Info(context.Background(), "non-existent-id") + if err == nil { + t.Error("Expected error for non-existent file ID, got nil") + } + + t.Logf("Retrieved file info - ID: %s, Path: %s, UserPath: %s", + fileInfo.ID, fileInfo.Path, fileInfo.UserPath) +} + +func TestList(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + // Use unique manager name for test isolation + managerName := fmt.Sprintf("test-list-%d", time.Now().UnixNano()) + manager, err := RegisterDefault(managerName) + if err != nil { + t.Fatalf("Failed to register manager: %v", err) + } + + // Clean up existing records first + m := model.Select("__yao.attachment") + _, err = m.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "uploader", Value: managerName}, + }, + }) + if err != nil { + t.Logf("Warning: Failed to clean up existing records: %v", err) + } + + // Upload multiple test files + testFiles := []struct { + filename string + content string + contentType string + groups []string + }{ + {"test1.txt", "Content of test file 1", "text/plain", []string{"group1"}}, + {"test2.txt", "Content of test file 2", "text/plain", []string{"group1"}}, + {"image1.jpg", "Image content 1", "image/jpeg", []string{"group2", "images"}}, + {"doc1.pdf", "PDF content", "application/pdf", []string{"group2", "docs"}}, + {"test3.txt", "Content of test file 3", "text/plain", []string{"group1"}}, + } + + uploadedFiles := make([]*File, 0, len(testFiles)) + for _, tf := range testFiles { + reader := strings.NewReader(tf.content) + fileHeader := &FileHeader{ + FileHeader: &multipart.FileHeader{ + Filename: tf.filename, + Size: int64(len(tf.content)), + Header: make(map[string][]string), + }, + } + fileHeader.Header.Set("Content-Type", tf.contentType) + + option := UploadOption{ + Groups: tf.groups, + } + + file, err := manager.Upload(context.Background(), fileHeader, reader, option) + if err != nil { + t.Fatalf("Failed to upload file %s: %v", tf.filename, err) + } + uploadedFiles = append(uploadedFiles, file) + } + + // Test basic listing (no filters, default pagination) + t.Run("BasicList", func(t *testing.T) { + result, err := manager.List(context.Background(), ListOption{ + Filters: map[string]interface{}{ + "uploader": managerName, + }, + }) + if err != nil { + t.Fatalf("Failed to list files: %v", err) + } + + if len(result.Files) != len(testFiles) { + t.Errorf("Expected %d files, got %d", len(testFiles), len(result.Files)) + } + + if result.Total != int64(len(testFiles)) { + t.Errorf("Expected total %d, got %d", len(testFiles), result.Total) + } + + if result.Page != 1 { + t.Errorf("Expected page 1, got %d", result.Page) + } + + if result.PageSize != 20 { + t.Errorf("Expected page size 20, got %d", result.PageSize) + } + }) + + // Test pagination + t.Run("Pagination", func(t *testing.T) { + result, err := manager.List(context.Background(), ListOption{ + Page: 1, + PageSize: 2, + Filters: map[string]interface{}{ + "uploader": managerName, + }, + }) + if err != nil { + t.Fatalf("Failed to list files with pagination: %v", err) + } + + if len(result.Files) != 2 { + t.Errorf("Expected 2 files, got %d", len(result.Files)) + } + + if result.Total != int64(len(testFiles)) { + t.Errorf("Expected total %d, got %d", len(testFiles), result.Total) + } + + if result.Page != 1 { + t.Errorf("Expected page 1, got %d", result.Page) + } + + if result.PageSize != 2 { + t.Errorf("Expected page size 2, got %d", result.PageSize) + } + + if result.TotalPages != 3 { // 5 files / 2 per page = 3 pages + t.Errorf("Expected 3 total pages, got %d", result.TotalPages) + } + }) + + // Test filtering by content type + t.Run("FilterByContentType", func(t *testing.T) { + result, err := manager.List(context.Background(), ListOption{ + Filters: map[string]interface{}{ + "uploader": managerName, + "content_type": "text/plain", + }, + }) + if err != nil { + t.Fatalf("Failed to list files with content type filter: %v", err) + } + + expectedCount := 3 // test1.txt, test2.txt, test3.txt + if len(result.Files) != expectedCount { + t.Errorf("Expected %d text files, got %d", expectedCount, len(result.Files)) + } + + // Verify all returned files are text/plain + for _, file := range result.Files { + if file.ContentType != "text/plain" { + t.Errorf("Expected content type 'text/plain', got '%s'", file.ContentType) + } + } + }) + + // Test wildcard filtering + t.Run("WildcardFilter", func(t *testing.T) { + result, err := manager.List(context.Background(), ListOption{ + Filters: map[string]interface{}{ + "uploader": managerName, + "content_type": "image/*", + }, + }) + if err != nil { + t.Fatalf("Failed to list files with wildcard filter: %v", err) + } + + expectedCount := 1 // image1.jpg + if len(result.Files) != expectedCount { + t.Errorf("Expected %d image files, got %d", expectedCount, len(result.Files)) + } + }) + + // Test ordering + t.Run("OrderBy", func(t *testing.T) { + result, err := manager.List(context.Background(), ListOption{ + OrderBy: "name asc", + Filters: map[string]interface{}{ + "uploader": managerName, + }, + }) + if err != nil { + t.Fatalf("Failed to list files with ordering: %v", err) + } + + if len(result.Files) != len(testFiles) { + t.Errorf("Expected %d files, got %d", len(testFiles), len(result.Files)) + } + + // Files should be ordered by name ascending + // Note: The actual filenames are generated, so we just check that they're sorted + for i := 1; i < len(result.Files); i++ { + if result.Files[i-1].Filename > result.Files[i].Filename { + t.Errorf("Files are not sorted by name ascending") + break + } + } + }) + + // Test field selection + t.Run("SelectFields", func(t *testing.T) { + result, err := manager.List(context.Background(), ListOption{ + Select: []string{"file_id", "name", "content_type"}, + Filters: map[string]interface{}{ + "uploader": managerName, + }, + }) + if err != nil { + t.Fatalf("Failed to list files with field selection: %v", err) + } + + if len(result.Files) != len(testFiles) { + t.Errorf("Expected %d files, got %d", len(testFiles), len(result.Files)) + } + + // Verify selected fields are populated + for _, file := range result.Files { + if file.ID == "" { + t.Error("Expected file_id to be populated") + } + if file.Filename == "" { + t.Error("Expected filename to be populated") + } + if file.ContentType == "" { + t.Error("Expected content_type to be populated") + } + } + }) + + t.Logf("Successfully tested list functionality with %d files", len(uploadedFiles)) +} diff --git a/attachment/s3/storage.go b/attachment/s3/storage.go index 49b7a935..5209a481 100644 --- a/attachment/s3/storage.go +++ b/attachment/s3/storage.go @@ -107,12 +107,12 @@ func New(options map[string]interface{}) (*Storage, error) { } // Upload upload file to S3 -func (storage *Storage) Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error) { +func (storage *Storage) Upload(ctx context.Context, path string, reader io.Reader, contentType string) (string, error) { if storage.client == nil { return "", fmt.Errorf("s3 client not initialized") } - key := filepath.Join(storage.prefix, fileID) + key := filepath.Join(storage.prefix, path) // Upload file _, err := storage.client.PutObject(ctx, &s3.PutObjectInput{ @@ -122,20 +122,20 @@ func (storage *Storage) Upload(ctx context.Context, fileID string, reader io.Rea ContentType: aws.String(contentType), }) if err != nil { - return "", fmt.Errorf("failed to upload file %s: %w", fileID, err) + return "", fmt.Errorf("failed to upload file %s: %w", path, err) } - return fileID, nil + return path, nil } // UploadChunk uploads a chunk of a file to S3 -func (storage *Storage) UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error { +func (storage *Storage) UploadChunk(ctx context.Context, path string, chunkIndex int, reader io.Reader, contentType string) error { if storage.client == nil { return fmt.Errorf("s3 client not initialized") } // Store chunks with a special prefix - chunkKey := filepath.Join(storage.prefix, ".chunks", fileID, fmt.Sprintf("chunk_%d", chunkIndex)) + chunkKey := filepath.Join(storage.prefix, ".chunks", path, fmt.Sprintf("chunk_%d", chunkIndex)) _, err := storage.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(storage.Bucket), @@ -144,19 +144,19 @@ func (storage *Storage) UploadChunk(ctx context.Context, fileID string, chunkInd ContentType: aws.String(contentType), }) if err != nil { - return fmt.Errorf("failed to upload chunk %s %d: %w", fileID, chunkIndex, err) + return fmt.Errorf("failed to upload chunk %s %d: %w", path, chunkIndex, err) } return nil } // MergeChunks merges all chunks into the final file in S3 -func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChunks int) error { +func (storage *Storage) MergeChunks(ctx context.Context, path string, totalChunks int) error { if storage.client == nil { return fmt.Errorf("s3 client not initialized") } - finalKey := filepath.Join(storage.prefix, fileID) + finalKey := filepath.Join(storage.prefix, path) // Create a buffer to hold the merged content var mergedContent bytes.Buffer @@ -164,7 +164,7 @@ func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChu // Download and merge chunks in order for i := 0; i < totalChunks; i++ { - chunkKey := filepath.Join(storage.prefix, ".chunks", fileID, fmt.Sprintf("chunk_%d", i)) + chunkKey := filepath.Join(storage.prefix, ".chunks", path, fmt.Sprintf("chunk_%d", i)) result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(storage.Bucket), @@ -182,7 +182,7 @@ func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChu _, err = io.Copy(&mergedContent, result.Body) result.Body.Close() if err != nil { - return fmt.Errorf("failed to copy chunk %s %d: %w", fileID, i, err) + return fmt.Errorf("failed to copy chunk %s %d: %w", path, i, err) } } @@ -199,12 +199,12 @@ func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChu ContentType: aws.String(contentType), }) if err != nil { - return fmt.Errorf("failed to upload merged file %s: %w", fileID, err) + return fmt.Errorf("failed to upload merged file %s: %w", path, err) } // Clean up chunks for i := 0; i < totalChunks; i++ { - chunkKey := filepath.Join(storage.prefix, ".chunks", fileID, fmt.Sprintf("chunk_%d", i)) + chunkKey := filepath.Join(storage.prefix, ".chunks", path, fmt.Sprintf("chunk_%d", i)) storage.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(storage.Bucket), Key: aws.String(chunkKey), @@ -215,23 +215,23 @@ func (storage *Storage) MergeChunks(ctx context.Context, fileID string, totalChu } // Reader read file from S3 -func (storage *Storage) Reader(ctx context.Context, fileID string) (io.ReadCloser, error) { +func (storage *Storage) Reader(ctx context.Context, path string) (io.ReadCloser, error) { if storage.client == nil { return nil, fmt.Errorf("s3 client not initialized") } - key := filepath.Join(storage.prefix, fileID) + key := filepath.Join(storage.prefix, path) result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(storage.Bucket), Key: aws.String(key), }) if err != nil { - return nil, fmt.Errorf("failed to get file %s: %w", fileID, err) + return nil, fmt.Errorf("failed to get file %s: %w", path, err) } // If the file is a gzip file, decompress it - if strings.HasSuffix(fileID, ".gz") { + if strings.HasSuffix(path, ".gz") { reader, err := gzip.NewReader(result.Body) if err != nil { return nil, err @@ -243,12 +243,12 @@ func (storage *Storage) Reader(ctx context.Context, fileID string) (io.ReadClose } // Download download file from S3 -func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadCloser, string, error) { +func (storage *Storage) Download(ctx context.Context, path string) (io.ReadCloser, string, error) { if storage.client == nil { return nil, "", fmt.Errorf("s3 client not initialized") } - key := filepath.Join(storage.prefix, fileID) + key := filepath.Join(storage.prefix, path) // Get object result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{ @@ -256,7 +256,7 @@ func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadClo Key: aws.String(key), }) if err != nil { - return nil, "", fmt.Errorf("failed to download file %s: %w", fileID, err) + return nil, "", fmt.Errorf("failed to download file %s: %w", path, err) } contentType := "application/octet-stream" @@ -265,7 +265,7 @@ func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadClo } // Try to detect content type from file extension - ext := filepath.Ext(strings.TrimSuffix(fileID, ".gz")) + ext := filepath.Ext(strings.TrimSuffix(path, ".gz")) switch strings.ToLower(ext) { case ".txt": contentType = "text/plain" @@ -301,7 +301,7 @@ func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadClo } // If the file is a gzip file, decompress it - if strings.HasSuffix(fileID, ".gz") { + if strings.HasSuffix(path, ".gz") { reader, err := gzip.NewReader(result.Body) if err != nil { return nil, "", err @@ -312,13 +312,24 @@ func (storage *Storage) Download(ctx context.Context, fileID string) (io.ReadClo return result.Body, contentType, nil } +// GetContent gets file content as bytes +func (storage *Storage) GetContent(ctx context.Context, path string) ([]byte, error) { + reader, err := storage.Reader(ctx, path) + if err != nil { + return nil, err + } + defer reader.Close() + + return io.ReadAll(reader) +} + // URL get file url with expiration -func (storage *Storage) URL(ctx context.Context, fileID string) string { +func (storage *Storage) URL(ctx context.Context, path string) string { if storage.client == nil { return "" } - key := filepath.Join(storage.prefix, fileID) + key := filepath.Join(storage.prefix, path) presignClient := s3.NewPresignClient(storage.client) request, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(storage.Bucket), @@ -333,12 +344,12 @@ func (storage *Storage) URL(ctx context.Context, fileID string) string { } // Exists checks if a file exists in S3 -func (storage *Storage) Exists(ctx context.Context, fileID string) bool { +func (storage *Storage) Exists(ctx context.Context, path string) bool { if storage.client == nil { return false } - key := filepath.Join(storage.prefix, fileID) + key := filepath.Join(storage.prefix, path) _, err := storage.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(storage.Bucket), Key: aws.String(key), @@ -347,12 +358,12 @@ func (storage *Storage) Exists(ctx context.Context, fileID string) bool { } // Delete deletes a file from S3 -func (storage *Storage) Delete(ctx context.Context, fileID string) error { +func (storage *Storage) Delete(ctx context.Context, path string) error { if storage.client == nil { return fmt.Errorf("s3 client not initialized") } - key := filepath.Join(storage.prefix, fileID) + key := filepath.Join(storage.prefix, path) _, err := storage.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(storage.Bucket), Key: aws.String(key), diff --git a/attachment/s3/storage_test.go b/attachment/s3/storage_test.go index c48fb35b..b8750d59 100644 --- a/attachment/s3/storage_test.go +++ b/attachment/s3/storage_test.go @@ -149,6 +149,11 @@ func TestS3Storage(t *testing.T) { assert.NoError(t, err) assert.Equal(t, content, data) + // Get file content directly + directContent, err := storage.GetContent(context.Background(), fileID) + assert.NoError(t, err) + assert.Equal(t, content, directContent) + // Delete file err = storage.Delete(context.Background(), fileID) assert.NoError(t, err) diff --git a/attachment/types.go b/attachment/types.go index 4b8323ca..f5526729 100644 --- a/attachment/types.go +++ b/attachment/types.go @@ -8,9 +8,48 @@ import ( "github.com/yaoapp/gou/types" ) +// FileManager defines the interface for file management operations. +// This interface provides abstraction for file operations, making it easier to: +// - Write unit tests with mock implementations +// - Switch between different storage backends +// - Maintain consistent API across different implementations +// +// Example usage: +// +// var fileManager FileManager = manager // Manager implements FileManager +// file, err := fileManager.Upload(ctx, header, reader, options) +// data, err := fileManager.Read(ctx, file.ID) +type FileManager interface { + // Upload uploads a file with optional chunked upload support + Upload(ctx context.Context, fileheader *FileHeader, reader io.Reader, option UploadOption) (*File, error) + + // Download downloads a file by its ID + Download(ctx context.Context, fileID string) (*FileResponse, error) + + // Read reads a file content as bytes + Read(ctx context.Context, fileID string) ([]byte, error) + + // ReadBase64 reads a file content as base64 encoded string + ReadBase64(ctx context.Context, fileID string) (string, error) + + // Info retrieves complete file information from database by file ID + Info(ctx context.Context, fileID string) (*File, error) + + // List retrieves files from database with pagination and filtering + List(ctx context.Context, option ListOption) (*ListResult, error) + + // Exists checks if a file exists + Exists(ctx context.Context, fileID string) bool + + // Delete deletes a file + Delete(ctx context.Context, fileID string) error +} + // File the file type File struct { ID string `json:"file_id"` + UserPath string `json:"user_path"` // User-specified complete file path + Path string `json:"path"` // Actual storage path Bytes int `json:"bytes"` CreatedAt int `json:"created_at"` Filename string `json:"filename"` @@ -35,13 +74,18 @@ type Attachment struct { Bytes int64 `json:"bytes,omitempty"` CreatedAt int64 `json:"created_at,omitempty"` FileID string `json:"file_id,omitempty"` + UserPath string `json:"user_path,omitempty"` // User-specified complete file path + Path string `json:"path,omitempty"` // Actual storage path Groups []string `json:"groups,omitempty"` - Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false + 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 } // Manager the manager struct type Manager struct { ManagerOption + Name string // Manager name for identification storage Storage maxsize int64 chunsize int64 @@ -50,14 +94,15 @@ type Manager struct { // Storage the storage interface type Storage interface { - Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error) - UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error - MergeChunks(ctx context.Context, fileID string, totalChunks int) error - Download(ctx context.Context, fileID string) (io.ReadCloser, string, error) - Reader(ctx context.Context, fileID string) (io.ReadCloser, error) - URL(ctx context.Context, fileID string) string - Exists(ctx context.Context, fileID string) bool - Delete(ctx context.Context, fileID string) error + Upload(ctx context.Context, path string, reader io.Reader, contentType string) (string, error) + UploadChunk(ctx context.Context, path string, chunkIndex int, reader io.Reader, contentType string) error + MergeChunks(ctx context.Context, path string, totalChunks int) error + Download(ctx context.Context, path string) (io.ReadCloser, string, error) + Reader(ctx context.Context, path string) (io.ReadCloser, error) + GetContent(ctx context.Context, path string) ([]byte, error) + URL(ctx context.Context, path string) string + Exists(ctx context.Context, path string) bool + Delete(ctx context.Context, path string) error } // ManagerOption the manager option @@ -83,6 +128,26 @@ 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 +} + +// ListOption defines options for listing files +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/*"} + 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 +} + +// ListResult contains the paginated list result +type ListResult struct { + Files []*File `json:"files"` // List of files + Total int64 `json:"total"` // Total count + Page int `json:"page"` // Current page + PageSize int `json:"page_size"` // Page size + TotalPages int `json:"total_pages"` // Total pages } // FileHeader the file header diff --git a/data/bindata.go b/data/bindata.go index ecefc4a0..2bf9c46d 100644 --- a/data/bindata.go +++ b/data/bindata.go @@ -280,7 +280,7 @@ func cuiSetupIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -300,7 +300,7 @@ func cuiV09IndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -320,7 +320,7 @@ func cuiV10IndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -340,7 +340,7 @@ func cuiV10Layouts__indexAsyncJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -360,7 +360,7 @@ func cuiV10UmiJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -380,7 +380,7 @@ func initEnv() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -400,7 +400,7 @@ func initVscodeSettingsJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -420,7 +420,7 @@ func initVscodeTypesRuntimeConsoleDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -440,7 +440,7 @@ func initVscodeTypesRuntimeExceptionDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -460,7 +460,7 @@ func initVscodeTypesRuntimeFsDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -480,7 +480,7 @@ func initVscodeTypesRuntimeGlobalDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -500,7 +500,7 @@ func initVscodeTypesRuntimeHttpDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -520,7 +520,7 @@ func initVscodeTypesRuntimeIoDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -540,7 +540,7 @@ func initVscodeTypesRuntimeLogDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -560,7 +560,7 @@ func initVscodeTypesRuntimeNeoDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -580,7 +580,7 @@ func initVscodeTypesRuntimeProcessFsDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -600,7 +600,7 @@ func initVscodeTypesRuntimeProcessHttpDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -620,7 +620,7 @@ func initVscodeTypesRuntimeProcessModelDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -640,7 +640,7 @@ func initVscodeTypesRuntimeProcessDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -660,7 +660,7 @@ func initVscodeTypesRuntimeQueryDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -680,7 +680,7 @@ func initVscodeTypesRuntimeStoreDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -700,7 +700,7 @@ func initVscodeTypesRuntimeSuiDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -720,7 +720,7 @@ func initVscodeTypesRuntimeTimeDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -740,7 +740,7 @@ func initVscodeTypesRuntimeDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -760,7 +760,7 @@ func initVscodeTypesSuiDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -780,7 +780,7 @@ func initAppYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -800,7 +800,7 @@ func initDataReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -820,7 +820,7 @@ func initDataTemplatesDefault__assetsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -840,7 +840,7 @@ func initDataTemplatesDefault__assetsImagesIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -860,7 +860,7 @@ func initDataTemplatesDefault__assetsImagesLogosLogo_colorSvg() (*asset, error) return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -880,7 +880,7 @@ func initDataTemplatesDefault__assetsImagesLogosWordmarkSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -900,7 +900,7 @@ func initDataTemplatesDefault__dataJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -920,7 +920,7 @@ func initDataTemplatesDefault__documentHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -940,7 +940,7 @@ func initDataTemplatesDefaultIndexIndexCss() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -960,7 +960,7 @@ func initDataTemplatesDefaultIndexIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -980,7 +980,7 @@ func initDataTemplatesDefaultIndexIndexJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1000,7 +1000,7 @@ func initDbReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1020,7 +1020,7 @@ func initFlowsMenuFlowYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1040,7 +1040,7 @@ func initFormsAccountFormYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1060,7 +1060,7 @@ func initIconsAppIcns() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1080,7 +1080,7 @@ func initIconsAppIco() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1100,7 +1100,7 @@ func initIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1120,7 +1120,7 @@ func initLoginsAdminLoginYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1140,7 +1140,7 @@ func initLogsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1160,7 +1160,7 @@ func initModelsAdminUserModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1180,7 +1180,7 @@ func initModelsTestsPetModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1200,7 +1200,7 @@ func initNeoNeoYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1220,7 +1220,7 @@ func initPublicReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1240,7 +1240,7 @@ func initPublicAssetsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1260,7 +1260,7 @@ func initPublicAssetsImagesIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1280,7 +1280,7 @@ func initPublicAssetsImagesLogosLogo_colorSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1300,7 +1300,7 @@ func initPublicAssetsImagesLogosWordmarkSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1320,7 +1320,7 @@ func initPublicAssetsLibsuiMinJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1340,7 +1340,7 @@ func initPublicAssetsLibsuiMinJsMap() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1360,7 +1360,7 @@ func initPublicIndexCfg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1380,7 +1380,7 @@ func initPublicIndexSui() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1400,7 +1400,7 @@ func initScriptsAccountTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1420,7 +1420,7 @@ func initScriptsAiNeoTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1440,7 +1440,7 @@ func initScriptsTestsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1460,7 +1460,7 @@ func initScriptsUtilsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1480,7 +1480,7 @@ func initSuisWebSuiYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1500,7 +1500,7 @@ func initTablesAccountTabYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1520,7 +1520,7 @@ func initTsconfigJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1540,7 +1540,7 @@ func libsuiAgentTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/agent.ts", size: 15267, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "libsui/agent.ts", size: 15267, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1560,7 +1560,7 @@ func libsuiIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/index.ts", size: 13049, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "libsui/index.ts", size: 13049, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1580,7 +1580,7 @@ func libsuiUtilsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1600,7 +1600,7 @@ func libsuiYaoTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1620,7 +1620,7 @@ func publicIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1640,7 +1640,7 @@ func uiIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1660,7 +1660,7 @@ func yaoDataIcons404Png() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1680,7 +1680,7 @@ func yaoDataIconsIconIcns() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1700,7 +1700,7 @@ func yaoDataIconsIconIco() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1720,7 +1720,7 @@ func yaoDataIconsIconPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1740,7 +1740,7 @@ func yaoDataIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1760,7 +1760,7 @@ func yaoFieldsModelTransJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1780,7 +1780,7 @@ func yaoLangsEnUsJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1800,7 +1800,7 @@ func yaoLangsZhCnGlobalYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1820,7 +1820,7 @@ func yaoLangsZhCnLoginsAdminLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1840,7 +1840,7 @@ func yaoLangsZhCnLoginsUserLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1860,7 +1860,7 @@ func yaoLangsZhHkGlobalYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1880,7 +1880,7 @@ func yaoLangsZhHkLoginsAdminLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1900,7 +1900,7 @@ func yaoLangsZhHkLoginsUserLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1920,12 +1920,12 @@ func yaoModelsAssistantModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/assistant.mod.yao", size: 4114, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/assistant.mod.yao", size: 4114, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } -var _yaoModelsAttachmentModYao = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x56\x4d\x6f\xdb\x38\x10\xbd\xfb\x57\x0c\x78\xce\x21\x58\x20\x0b\xd8\xb7\xdd\xec\xb6\x08\x8a\x16\x01\xda\xa0\x87\x20\x30\x28\x69\x24\xb3\xa0\x48\x95\x1c\x22\x75\x02\xff\xf7\x82\xb4\x2c\x53\x0c\x6d\x47\x89\xd1\x93\xe1\xf9\x78\x7c\xf3\x38\x9a\xe1\xf3\x0c\x80\x29\xde\x22\x5b\x00\xe3\x44\xbc\x5c\xb5\xa8\x88\x5d\x78\xbb\xe4\x05\x4a\xef\xf8\x27\x71\x54\x68\x4b\x23\x3a\x12\x5a\x8d\xdd\x40\xbc\x90\x08\xb5\x36\x60\x49\x1b\xa1\x1a\xa8\x85\x44\xd8\x23\x5b\x78\x14\xb4\x82\x16\x89\x57\x9c\x38\x70\x55\x01\x2f\x4b\xb4\x16\x4a\xad\xc8\x68\xb9\x3d\x82\x78\x63\xd9\x02\xee\x99\x5d\x5b\xc2\x96\x3d\x04\x6b\xe1\x84\x24\xe1\x0f\x25\xe3\x30\x98\x0c\xf2\x4a\x2b\xb9\x8e\x6d\x56\x1b\x62\x0b\x98\xcf\xe7\xf3\x1e\xac\x90\xbe\x42\x5f\xed\xe1\x7a\x01\x58\xa9\xdb\xf0\x37\x53\x14\x9b\x01\x6c\x02\x5a\xa9\xa5\x6b\x55\x60\x17\xb2\xb6\xa8\x11\xae\xa8\x7a\x3c\x7f\xf4\xba\x0b\xb6\x9b\xff\xf6\xb6\x8c\xae\x10\xfb\x23\x16\x77\x4a\xfc\x74\xb1\x7e\x20\x2a\x54\x24\x6a\x81\x86\x85\xf8\xcd\x45\x9e\x84\xd7\x7d\x99\x63\x62\xc9\xdf\x4b\x86\xcd\x07\x7f\x53\x07\x78\x04\x5f\x74\xf4\x3e\x1b\x55\x43\x2b\xb6\x80\xbf\xae\xae\x06\xa3\x72\x52\xf6\x92\xd7\x5c\x5a\x1c\x1c\x2e\x94\x13\x5d\x55\xb0\x0a\x55\xe1\xaf\xde\x78\xb4\x26\x37\xa9\x9e\x3b\x8b\xe6\xa0\xae\xde\xf7\xfe\x7a\x5e\xcd\xbc\x71\x68\xe9\x25\xf7\x42\x6b\x89\x5c\x65\xc8\x7f\x1c\x27\x44\xd4\xbf\xaf\x90\x56\x68\xc0\x75\x52\xf3\x0a\x2b\x28\xd6\x10\xe0\xc1\xd9\xb8\x92\x0a\x6b\xee\x24\xbd\x9d\x73\xcb\x15\x6f\x62\xc4\x93\x8a\x7f\x4e\x33\xd2\x0e\xea\x21\x21\x20\x65\x34\xbf\xbc\x3c\xa3\xe6\x7e\x9e\xa0\xa2\xe5\xf8\xb0\x93\x45\x5c\x6f\xd3\xe0\xdb\x28\x2d\xad\xa4\x07\xff\x33\x95\x84\xdf\xd7\x57\xf0\x65\x14\x9e\x32\x1f\x83\x0d\x8c\xaf\xce\xca\xb8\x73\x85\x14\xe5\x94\x86\xbf\x4d\x32\x32\x1d\x1f\x56\x89\xb0\x90\x82\xbf\xbb\xd3\x6d\xa9\x73\x2d\xf2\xc3\xea\x1c\xd3\xaf\xe3\xe8\x54\xdf\x7e\x9b\x25\x98\x91\xa4\xa7\x87\xc5\x93\xe8\x26\xcd\x8a\x51\xfc\x11\xe1\x3c\x70\x87\xd5\x19\x95\x2b\xd6\x84\x36\xc3\x55\x34\x37\x8a\x70\x34\x0b\x06\xba\xff\x8e\x73\x52\xfd\xac\x78\x42\x10\x0a\x12\xe8\x73\xcc\x03\x29\xb1\xf4\x8f\x96\x69\x7b\xf1\x7a\xc8\x3b\xb4\x4d\x3e\x29\xfd\x28\xb1\x6a\xfc\x58\x18\x62\x8f\x6f\x97\xfc\xd7\xf6\xb6\xb5\x68\x89\x93\xcb\xdc\x02\x2a\xd7\xe6\xfa\x37\x09\x4f\x2f\xa0\x33\xda\x77\xb0\x7f\xbc\xa5\xc8\x7a\xf7\xe6\xbb\xef\x2d\x7e\xa7\x87\x3d\x14\x4b\x37\x18\xa3\x56\xdb\xd5\x93\xc4\x05\xdb\x38\x6c\x9b\xba\xac\xb9\x90\x99\xfc\x9d\xbd\x37\x3f\x64\x5a\x39\xc3\x68\xc2\xe0\x32\xba\x31\x68\x33\x6a\x1e\xec\x8f\xdb\x17\x29\x91\xa2\xb7\x7b\x31\x77\xd0\x20\x54\xad\x4d\xcb\x83\x94\x93\x5a\xe3\x28\x73\x34\x46\x4f\x59\xd6\xff\x8f\xe3\x23\xce\xc1\x73\x82\xe5\xdf\xa7\x58\xce\xfa\xcb\x61\x06\x65\x00\xf1\x0f\xe6\xe7\xed\x0b\x7a\x7b\xeb\xe1\x05\xbd\x8d\x19\xfa\xea\x19\x18\x89\x16\x2d\xf1\xb6\xb3\xbb\x0f\xc2\x3f\xe8\x6b\x5a\x56\x28\x91\x42\x56\x18\x00\xb0\x99\x6d\x66\xbf\x03\x00\x00\xff\xff\xaf\xc4\x06\x40\xc0\x0c\x00\x00") +var _yaoModelsAttachmentModYao = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x57\x5f\x6f\xd3\x3e\x14\x7d\xef\xa7\xb8\xca\xf3\x7e\xd2\x7e\x48\x43\x6a\xdf\xc6\x06\x68\x12\x82\x09\x98\x78\x98\xa6\xca\x4d\x6e\x52\x23\xc7\x0e\xf6\x8d\xa0\x9b\xfa\xdd\x91\x9d\xa4\xb5\x53\xa7\x5d\x02\xe3\xa9\xea\xfd\x73\x7c\xce\xf5\xb5\x7d\xf3\x34\x03\x48\x24\x2b\x31\x59\x40\xc2\x88\x58\xba\x2e\x51\x52\x72\x66\xed\x82\xad\x50\x58\xc7\x65\xcf\x91\xa1\x49\x35\xaf\x88\x2b\x19\xba\x81\xd8\x4a\x20\xe4\x4a\x83\x21\xa5\xb9\x2c\x20\xe7\x02\x61\x8f\x6c\xe0\x27\xa7\x35\x94\x48\x2c\x63\xc4\x80\xc9\x0c\x58\x9a\xa2\x31\x90\x2a\x49\x5a\x89\x66\x09\x62\x85\x49\x16\x70\x9f\x98\x8d\x21\x2c\x93\x07\x67\x5d\xd5\x5c\x10\xb7\x8b\x92\xae\xd1\x99\x34\xb2\x4c\x49\xb1\xf1\x6d\x46\x69\x4a\x16\x30\x9f\xcf\xe7\x2d\xd8\x4a\x58\x85\x56\xed\xb0\x5e\x80\x24\x55\xa5\xfb\x1b\x11\x95\xcc\x00\xb6\x0e\x2d\x55\xa2\x2e\xa5\x63\xe7\xb2\x1a\x54\x0f\x97\x67\x2d\x9e\x5d\x7a\x53\x39\xdb\xcd\xf5\xde\x16\xa9\x2b\xf8\x7e\x8f\xc5\x9d\xe4\x3f\x6a\xbf\x7e\xc0\x33\x94\xc4\x73\x8e\x3a\x71\xf1\xdb\xb3\x38\x09\x5b\xf7\x65\x8c\x89\x21\xbb\x2f\x11\x36\xef\xec\x4e\x0d\xf0\x70\x3e\x6f\xe9\x7d\x36\xca\x82\xd6\xc9\x02\x5e\x5d\x5c\xec\x8c\xb2\x16\xa2\x2d\x79\xce\x84\xc1\x9d\xa3\x76\x72\xbc\xad\x72\x56\x2e\x33\xfc\xd5\x1a\x8f\x6a\xaa\x2b\xa1\x58\xe6\x2f\x7f\x52\xd4\xdd\x41\x4a\x5f\x55\x07\x0a\x0e\x2b\x22\xec\xfc\xfc\xb4\xb0\x67\x4b\xb0\x4d\x8e\x92\x96\xe1\x62\x27\x65\x5c\x35\x69\xf0\x35\x48\xeb\x4b\x69\xc1\xff\x8d\x12\xf7\xfb\x7c\x05\x1f\x83\xf0\x3e\xf3\x10\x6c\xc7\xf8\xe2\xaf\x32\xae\xb5\x18\xd3\x39\x9f\x3f\x0c\xf3\x0d\x9c\x3b\xba\xff\x9f\xc7\xf9\x46\xbb\xdd\x89\x38\xca\xd7\xbf\x66\x9f\xcf\xfb\x3a\x96\xd5\xe7\x1f\x85\x7e\x29\x1d\x23\x7b\xfd\x78\x8f\x8f\xeb\xed\x89\xf7\x8c\x41\xbd\xac\x18\xad\xc7\xb4\x8b\x41\x0d\xb7\x41\x8e\x7f\x8f\x1b\xd4\xff\x99\x0a\x53\x7b\x7d\x66\x90\xaa\xb2\x12\x48\xd8\xbc\x8e\xe1\x4a\xd3\x76\xe1\xa4\xa6\x91\x72\xbe\x90\xd2\xac\xc0\x61\x45\x97\x29\xd5\x4c\xb8\x67\xde\xc6\x59\x78\xf7\xee\xd3\xba\x51\x35\x42\xd0\xc4\xe3\x5c\x68\x55\x57\xe6\x50\xd3\x77\x13\x34\x75\xa7\xe8\x7d\x2f\xbc\xdf\x58\x7d\xb8\x5e\xc5\x8f\x53\x79\xe4\xd5\x21\x91\x95\x52\x02\x59\x94\x4b\x10\xef\x31\xf9\xb6\x46\x5a\xa3\x6e\xfa\x82\x1b\xb0\xc0\x15\x7a\xaf\x78\x86\x39\xab\x05\x4d\xaf\xda\x6a\x43\x18\x29\xda\x8a\x17\x37\x92\xb0\x08\xde\xf6\x8e\xee\x9b\x30\xa7\x5f\x39\xc3\x1f\x11\xb8\x84\x1e\xf4\x9f\xef\xb0\x21\x46\x75\x84\x2c\xca\xba\x8c\xf6\x6c\x18\xde\xe7\x59\x69\x65\x07\x4d\x3b\x95\xf6\x91\x55\x37\xcc\xde\xb7\x16\xe8\xa6\x0d\xff\x78\xec\x8c\xde\x8e\x74\x7a\x7a\x71\xce\x16\x86\x35\xa9\xcb\x9c\x71\x11\xc9\xef\xec\xad\xf9\x21\xb2\xe3\x11\x46\x23\xce\xbf\x56\x85\x46\x13\xa9\xe6\xe0\x1d\x70\x7b\x90\xe2\x55\xf4\x76\x5f\xcc\x0e\x1a\xb8\xcc\x95\x2e\xd9\xc0\xab\x72\xe4\x8a\x3e\xca\x1c\xb5\x56\x63\x46\xbe\xb7\x61\xbc\xc7\xd9\x79\x4e\xb0\x7c\x3d\x91\x65\x2a\xb8\x9d\xeb\x46\x4d\xdc\x57\x2e\x67\x68\xe6\x6e\xbd\x53\xa6\xee\x69\x4f\x84\xaa\x50\x8e\xe2\xff\xa9\x42\x39\x40\xbe\x71\xbd\x04\xf9\x59\x7b\x38\x12\x8d\xc2\x6d\xa2\xfd\x12\x7b\x6a\x3e\xcd\x9a\x53\xe7\x3e\xcd\x9a\x98\xdd\xb9\x7e\x82\x84\x78\x89\x86\x58\x59\x99\x6e\x11\xfb\xa5\x98\xd3\x32\x43\xfb\x16\x9b\xee\x9e\x82\xed\x6c\x3b\xfb\x1d\x00\x00\xff\xff\x6d\x48\xa5\x86\x19\x0f\x00\x00") func yaoModelsAttachmentModYaoBytes() ([]byte, error) { return bindataRead( @@ -1940,7 +1940,7 @@ func yaoModelsAttachmentModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 3264, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 3865, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1960,7 +1960,7 @@ func yaoModelsAuditModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1980,7 +1980,7 @@ func yaoModelsChatModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/chat.mod.yao", size: 1444, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/chat.mod.yao", size: 1444, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2000,7 +2000,7 @@ func yaoModelsConfigModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2020,7 +2020,7 @@ func yaoModelsDslModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3806, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3806, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2040,7 +2040,7 @@ func yaoModelsHistoryModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/history.mod.yao", size: 2902, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/history.mod.yao", size: 2902, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2060,7 +2060,7 @@ func yaoModelsKbModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/kb.mod.yao", size: 2881, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/kb.mod.yao", size: 2881, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2080,7 +2080,7 @@ func yaoModelsUserModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 6775, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 6775, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2100,7 +2100,7 @@ func yaoReleaseAppYaz() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2120,7 +2120,7 @@ func yaoStoresAgentCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2140,7 +2140,7 @@ func yaoStoresAgentMemoryBadgerYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2160,7 +2160,7 @@ func yaoStoresCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2180,7 +2180,7 @@ func yaoStoresKbCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2200,7 +2200,7 @@ func yaoStoresKbStoreBadgerYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/kb/store.badger.yao", size: 349, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/kb/store.badger.yao", size: 349, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2220,7 +2220,7 @@ func yaoStoresOauthCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2240,7 +2240,7 @@ func yaoStoresOauthClientBadgerYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/client.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/client.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2260,7 +2260,7 @@ func yaoStoresOauthStoreBadgerYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/store.badger.yao", size: 376, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/store.badger.yao", size: 376, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2280,7 +2280,7 @@ func yaoStoresStoreBadgerYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/store.badger.yao", size: 341, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/stores/store.badger.yao", size: 341, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2300,7 +2300,7 @@ func yaoUploadersAttachmentLocalYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1131, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)} + info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1131, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)} a := &asset{bytes: bytes, info: info} return a, nil } diff --git a/openapi/README.md b/openapi/README.md index 97843da3..ae480ed6 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -271,6 +271,41 @@ The DSL Management API provides: All DSL endpoints require OAuth authentication. +## File Management API + +Comprehensive API for managing file uploads, downloads, and file operations with support for multiple storage backends. + +**[View Full File Management API Documentation →](file/README.md)** + +The File Management API provides: + +- **File Upload**: Single and chunked file uploads with compression support +- **File Listing**: Paginated file listing with filtering and sorting capabilities +- **File Retrieval**: Get file metadata and download file content with accurate headers +- **File Management**: Check file existence and delete files +- **Storage Flexibility**: Support for local, S3, and custom storage backends +- **Security**: URL-safe file IDs and path validation +- **Optimized Content Delivery**: Direct content reading with database-driven metadata + +**Key Endpoints:** + +- `POST /files/{uploaderID}` - Upload files (supports chunked upload) +- `GET /files/{uploaderID}` - List files with pagination and filters +- `GET /files/{uploaderID}/{fileID}` - Get file metadata +- `GET /files/{uploaderID}/{fileID}/content` - Download file content +- `GET /files/{uploaderID}/{fileID}/exists` - Check file existence +- `DELETE /files/{uploaderID}/{fileID}` - Delete file + +**Advanced Features:** + +- **Chunked Upload**: Large file support with reliable chunk-based uploading +- **Compression**: Automatic gzip and image compression options +- **Metadata Management**: File organization with groups, paths, and user identifiers +- **Multiple Storage**: Local filesystem and S3-compatible cloud storage +- **Optimized Content Delivery**: Direct file reading with accurate metadata headers + +All file endpoints require OAuth authentication. + ## Error Responses All endpoints return standardized error responses: @@ -385,6 +420,35 @@ curl -X POST "/v1/dsl/create/model" \ }' ``` +### File Upload and Management + +1. **Upload a file with metadata**: + +```bash +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {access_token}" \ + -F "file=@document.pdf" \ + -F "path=documents/reports/quarterly-report.pdf" \ + -F "groups=documents,reports" \ + -F "client_id=app123" \ + -F "gzip=true" +``` + +2. **List and filter files**: + +```bash +curl -X GET "/v1/files/default?status=completed&content_type=application/pdf&page=1&page_size=10" \ + -H "Authorization: Bearer {access_token}" +``` + +3. **Download file content** (with optimized delivery): + +```bash +curl -X GET "/v1/files/default/{file_id}/content" \ + -H "Authorization: Bearer {access_token}" \ + --output downloaded-document.pdf +``` + ## Configuration The OpenAPI server is configured through `openapi/openapi.yao`. diff --git a/openapi/file/README.md b/openapi/file/README.md new file mode 100644 index 00000000..c8df62b3 --- /dev/null +++ b/openapi/file/README.md @@ -0,0 +1,525 @@ +# File Management API + +This document describes the RESTful API for managing file uploads, downloads, and file operations in Yao applications. + +## Base URL + +All endpoints are prefixed with the configured base URL followed by `/files` (e.g., `/v1/files`). + +## Authentication + +All endpoints require OAuth authentication via the configured OAuth provider. + +## File Operations + +The File Management API provides comprehensive file handling capabilities including: + +- **File Upload** - Single and chunked file uploads with compression support +- **File Listing** - Paginated file listing with filtering and sorting +- **File Retrieval** - Get file metadata and download file content with accurate headers +- **File Management** - Check existence and delete files +- **Storage Flexibility** - Support for local and cloud storage backends +- **Optimized Content Delivery** - Direct content reading with database-driven metadata headers + +## Endpoints + +### File Upload + +Upload files with support for chunked uploads, compression, and metadata. + +``` +POST /files/{uploaderID} +``` + +**Parameters:** + +- `uploaderID` (path): Uploader/manager identifier + +**Form Data:** + +- `file` (required): The file to upload +- `original_filename` (optional): Original filename (defaults to uploaded filename) +- `path` (optional): User-specified file path (defaults to original_filename) +- `groups` (optional): Comma-separated list of groups for directory organization +- `client_id` (optional): Client identifier +- `openid` (optional): OpenID identifier +- `gzip` (optional): Enable gzip compression ("true"/"false") +- `compress_image` (optional): Enable image compression ("true"/"false") +- `compress_size` (optional): Target compression size in bytes + +**Chunked Upload Headers:** + +- `Content-Range`: Byte range for chunk (e.g., "bytes 0-1023/2048") +- `Content-Sync`: Synchronization header for chunks +- `Content-Uid`: Unique identifier for chunked upload session + +**Example:** + +```bash +# Simple file upload +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -F "file=@document.pdf" \ + -F "path=documents/reports/quarterly-report.pdf" \ + -F "groups=documents,reports" \ + -F "client_id=app123" \ + -F "gzip=true" + +# Chunked upload (first chunk) +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Range: bytes 0-1023/2048" \ + -H "Content-Sync: chunk-upload" \ + -H "Content-Uid: unique-upload-id" \ + -F "file=@chunk1.bin" +``` + +**Response:** + +```json +{ + "file_id": "a1b2c3d4e5f6789012345678901234567890abcd", + "user_path": "documents/reports/quarterly-report.pdf", + "path": "documents/reports/quarterly-report.pdf", + "filename": "quarterly-report.pdf", + "content_type": "application/pdf", + "bytes": 2048576, + "gzip": true, + "status": "completed", + "created_at": 1640995200 +} +``` + +### List Files + +List files with pagination, filtering, and sorting capabilities. + +``` +GET /files/{uploaderID}?page={page}&page_size={page_size}&status={status}&content_type={content_type}&name={name}&order_by={order_by}&select={select} +``` + +**Parameters:** + +- `uploaderID` (path): Uploader/manager identifier + +**Query Parameters:** + +- `page` (optional): Page number (default: 1) +- `page_size` (optional): Items per page (default: 20, max: 100) +- `status` (optional): Filter by file status +- `content_type` (optional): Filter by content type +- `name` (optional): Filter by filename (supports wildcard matching) +- `order_by` (optional): Sort field and direction (default: "created_at desc") +- `select` (optional): Comma-separated list of fields to return + +**Example:** + +```bash +# List files with pagination +curl -X GET "/v1/files/default?page=1&page_size=10" \ + -H "Authorization: Bearer {token}" + +# List files with filters +curl -X GET "/v1/files/default?status=completed&content_type=image/jpeg&name=photo*" \ + -H "Authorization: Bearer {token}" + +# List with custom ordering and field selection +curl -X GET "/v1/files/default?order_by=bytes desc&select=file_id,filename,bytes" \ + -H "Authorization: Bearer {token}" +``` + +**Response:** + +```json +{ + "files": [ + { + "file_id": "a1b2c3d4e5f6789012345678901234567890abcd", + "user_path": "documents/reports/quarterly-report.pdf", + "path": "documents/reports/quarterly-report.pdf", + "filename": "quarterly-report.pdf", + "content_type": "application/pdf", + "bytes": 2048576, + "gzip": true, + "status": "completed", + "created_at": 1640995200 + } + ], + "total": 150, + "page": 1, + "page_size": 20, + "total_pages": 8 +} +``` + +### Retrieve File Information + +Get detailed metadata for a specific file. + +``` +GET /files/{uploaderID}/{fileID} +``` + +**Parameters:** + +- `uploaderID` (path): Uploader/manager identifier +- `fileID` (path): File identifier (URL-encoded) + +**Example:** + +```bash +curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \ + -H "Authorization: Bearer {token}" +``` + +**Response:** + +```json +{ + "file_id": "a1b2c3d4e5f6789012345678901234567890abcd", + "user_path": "documents/reports/quarterly-report.pdf", + "path": "documents/reports/quarterly-report.pdf", + "filename": "quarterly-report.pdf", + "content_type": "application/pdf", + "bytes": 2048576, + "gzip": true, + "status": "completed", + "created_at": 1640995200, + "uploader": "default", + "client_id": "app123", + "openid": "user456", + "groups": ["documents", "reports"] +} +``` + +### Download File Content + +Download the actual file content directly from storage. + +``` +GET /files/{uploaderID}/{fileID}/content +``` + +**Parameters:** + +- `uploaderID` (path): Uploader/manager identifier +- `fileID` (path): File identifier (URL-encoded) + +**Example:** + +```bash +curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/content" \ + -H "Authorization: Bearer {token}" \ + --output downloaded-file.pdf +``` + +**Response:** + +Returns the raw file content with metadata-driven headers: + +``` +Content-Type: application/pdf +Content-Disposition: attachment; filename="quarterly-report.pdf" +Content-Length: 2048576 +``` + +**Implementation Details:** + +- File metadata is retrieved from the database to set accurate response headers +- Content is read directly using the storage manager's Read method +- Headers include the actual filename, precise content type, and content length +- Automatic decompression is handled transparently for gzipped files + +### Check File Existence + +Check if a file exists without downloading it. + +``` +GET /files/{uploaderID}/{fileID}/exists +``` + +**Parameters:** + +- `uploaderID` (path): Uploader/manager identifier +- `fileID` (path): File identifier (URL-encoded) + +**Example:** + +```bash +curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/exists" \ + -H "Authorization: Bearer {token}" +``` + +**Response:** + +```json +{ + "exists": true, + "file_id": "a1b2c3d4e5f6789012345678901234567890abcd" +} +``` + +### Delete File + +Delete a file and its metadata. + +``` +DELETE /files/{uploaderID}/{fileID} +``` + +**Parameters:** + +- `uploaderID` (path): Uploader/manager identifier +- `fileID` (path): File identifier (URL-encoded) + +**Example:** + +```bash +curl -X DELETE "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \ + -H "Authorization: Bearer {token}" +``` + +**Response:** + +```json +{ + "message": "File deleted successfully", + "file_id": "a1b2c3d4e5f6789012345678901234567890abcd" +} +``` + +## File ID System + +The File Management API uses a secure file ID system: + +- **File ID**: A URL-safe MD5 hash that serves as a public alias for the file +- **Storage Path**: The actual file system path where the file is stored +- **User Path**: The original path specified by the user for organization + +This system provides security by hiding internal storage paths while maintaining a consistent public API. + +## Storage Backends + +The API supports multiple storage backends: + +- **Local Storage**: Files stored on the local file system +- **S3 Storage**: Files stored in Amazon S3 or S3-compatible services +- **Custom Storage**: Extensible storage interface for custom implementations + +## Compression Support + +### Gzip Compression + +Files can be automatically compressed using gzip: + +- Set `gzip=true` in upload form data +- Compressed files are automatically decompressed when downloaded +- Storage path includes `.gz` extension for compressed files +- File ID remains unchanged (hash of uncompressed path) + +### Image Compression + +Images can be compressed for storage optimization: + +- Set `compress_image=true` in upload form data +- Optionally specify `compress_size` for target size in bytes +- Maintains image quality while reducing file size + +## Chunked Upload + +For large files, use chunked upload for better reliability: + +1. **Split file into chunks** (typically 1MB each) +2. **Upload each chunk** with appropriate headers: + - `Content-Range`: Byte range of the chunk + - `Content-Sync`: Set to "chunk-upload" + - `Content-Uid`: Unique identifier for the upload session +3. **Final chunk** triggers automatic merge and file completion + +**Example Chunked Upload:** + +```bash +# Upload chunk 1 +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Range: bytes 0-1048575/3145728" \ + -H "Content-Sync: chunk-upload" \ + -H "Content-Uid: upload-session-123" \ + -F "file=@chunk1.bin" + +# Upload chunk 2 +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Range: bytes 1048576-2097151/3145728" \ + -H "Content-Sync: chunk-upload" \ + -H "Content-Uid: upload-session-123" \ + -F "file=@chunk2.bin" + +# Upload final chunk (triggers merge) +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Range: bytes 2097152-3145727/3145728" \ + -H "Content-Sync: chunk-upload" \ + -H "Content-Uid: upload-session-123" \ + -F "file=@chunk3.bin" +``` + +## Error Responses + +All endpoints return standardized error responses: + +```json +{ + "error": "invalid_request", + "error_description": "File ID is required" +} +``` + +**Common HTTP Status Codes:** + +- `200` - Success +- `400` - Bad Request (invalid parameters, missing file) +- `401` - Unauthorized (authentication required) +- `404` - Not Found (uploader or file not found) +- `500` - Internal Server Error (upload/storage failure) + +**Common Error Scenarios:** + +- `Uploader not found` - Invalid uploader ID +- `File is required` - No file provided in upload +- `File not found` - File ID does not exist +- `Failed to upload file` - Storage or processing error + +## Example Workflows + +### Simple File Upload and Download + +1. **Upload a file:** + +```bash +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -F "file=@document.pdf" \ + -F "path=documents/important-doc.pdf" \ + -F "groups=documents" +``` + +2. **List files to find the uploaded file:** + +```bash +curl -X GET "/v1/files/default?name=important-doc*" \ + -H "Authorization: Bearer {token}" +``` + +3. **Download the file:** + +```bash +curl -X GET "/v1/files/default/{file_id}/content" \ + -H "Authorization: Bearer {token}" \ + --output downloaded-document.pdf +``` + +### Large File Chunked Upload + +1. **Split large file into chunks:** + +```bash +split -b 1048576 largefile.zip chunk_ +``` + +2. **Upload chunks sequentially:** + +```bash +#!/bin/bash +TOTAL_SIZE=$(stat -c%s largefile.zip) +CHUNK_SIZE=1048576 +UPLOAD_ID="upload-$(date +%s)" + +for i in chunk_*; do + START=$((CHUNK_SIZE * (${i#chunk_} - 1))) + END=$((START + $(stat -c%s $i) - 1)) + + curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Range: bytes ${START}-${END}/${TOTAL_SIZE}" \ + -H "Content-Sync: chunk-upload" \ + -H "Content-Uid: ${UPLOAD_ID}" \ + -F "file=@${i}" +done +``` + +### File Management with Metadata + +1. **Upload with comprehensive metadata:** + +```bash +curl -X POST "/v1/files/default" \ + -H "Authorization: Bearer {token}" \ + -F "file=@report.pdf" \ + -F "path=reports/2024/quarterly-report.pdf" \ + -F "groups=reports,2024,quarterly" \ + -F "client_id=dashboard-app" \ + -F "openid=user123" \ + -F "gzip=true" +``` + +2. **List files with filters:** + +```bash +curl -X GET "/v1/files/default?status=completed&content_type=application/pdf&order_by=created_at desc" \ + -H "Authorization: Bearer {token}" +``` + +3. **Get detailed file information:** + +```bash +curl -X GET "/v1/files/default/{file_id}" \ + -H "Authorization: Bearer {token}" +``` + +4. **Clean up old files:** + +```bash +curl -X DELETE "/v1/files/default/{file_id}" \ + -H "Authorization: Bearer {token}" +``` + +## Performance Optimizations + +### Content Delivery Optimization + +The File Management API implements several performance optimizations for efficient content delivery: + +- **Direct Content Reading**: The `/content` endpoint uses direct file reading instead of streaming, reducing overhead +- **Database-Driven Headers**: Response headers are generated from accurate database metadata rather than file system inspection +- **Optimized Header Information**: Includes precise content length, actual filename, and accurate MIME types +- **Transparent Decompression**: Gzipped files are automatically decompressed without additional processing overhead + +### Implementation Benefits + +- **Reduced Latency**: Direct content reading eliminates streaming overhead +- **Accurate Metadata**: Headers reflect database-stored information for consistency +- **Better Caching**: Content-Length headers improve browser and proxy caching behavior +- **Resource Efficiency**: Single database query for metadata followed by direct file access + +## Security Considerations + +### Access Control + +- All endpoints require valid OAuth authentication +- File access is scoped to the uploader/manager level +- File IDs are cryptographically secure (MD5 hash) + +### File Validation + +- Content type validation based on file headers +- File size limits enforced by uploader configuration +- Allowed file type restrictions per uploader + +### Path Security + +- User paths are normalized and validated +- Internal storage paths are hidden from public API +- Directory traversal attacks prevented + +This File Management API provides a robust, secure, and scalable solution for handling file operations in Yao applications. diff --git a/openapi/file/file.go b/openapi/file/file.go new file mode 100644 index 00000000..af19e882 --- /dev/null +++ b/openapi/file/file.go @@ -0,0 +1,470 @@ +package file + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/attachment" + "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" +) + +// Attach attaches the file management handlers to the router +func Attach(group *gin.RouterGroup, oauth types.OAuth) { + // https://api.openai.com/v1/files + // Protect all endpoints with OAuth + group.Use(oauth.Guard) + + // Upload a file (supports chunked upload) + group.POST("/files/:uploaderID", upload) + + // List files + group.GET("/files/:uploaderID", list) + + // Retrieve file + group.GET("/files/:uploaderID/:fileID", retrieve) + + // Delete file + group.DELETE("/files/:uploaderID/:fileID", delete) + + // Retrieve file content + group.GET("/files/:uploaderID/:fileID/content", content) + + // Check if file exists + group.GET("/files/:uploaderID/:fileID/exists", exists) +} + +// upload handles file upload +func upload(c *gin.Context) { + // Get the uploader ID from the URL path + uploaderID := c.Param("uploaderID") + if uploaderID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get the attachment manager + manager, exists := attachment.Managers[uploaderID] + if !exists { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader not found: " + uploaderID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Parse multipart form + err := c.Request.ParseMultipartForm(32 << 20) // 32 MB max memory + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to parse multipart form: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get the file from the form + file, fileHeader, err := c.Request.FormFile("file") + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + 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 + } + + // Upload the file + uploadedFile, err := manager.Upload(c.Request.Context(), header, file, uploadOption) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to upload file: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Return the uploaded file info + response.RespondWithSuccess(c, response.StatusOK, uploadedFile) +} + +// list handles file listing with pagination and filtering +func list(c *gin.Context) { + uploaderID := c.Param("uploaderID") + if uploaderID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get the attachment manager + manager, ok := attachment.Managers[uploaderID] + if !ok { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader not found: " + uploaderID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Parse query parameters + page := 1 + if pageStr := c.Query("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + pageSize := 20 + if pageSizeStr := c.Query("page_size"); pageSizeStr != "" { + if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 { + pageSize = ps + } + } + + // Parse filters + filters := make(map[string]interface{}) + filters["uploader"] = uploaderID // Always filter by current uploader + + if status := c.Query("status"); status != "" { + filters["status"] = status + } + if contentType := c.Query("content_type"); contentType != "" { + filters["content_type"] = contentType + } + if name := c.Query("name"); name != "" { + filters["name"] = name + "*" // Wildcard search + } + + // Parse order by + orderBy := c.Query("order_by") + if orderBy == "" { + orderBy = "created_at desc" + } + + // Parse select fields + var selectFields []string + if selectStr := c.Query("select"); selectStr != "" { + selectFields = strings.Split(selectStr, ",") + for i, field := range selectFields { + selectFields[i] = strings.TrimSpace(field) + } + } + + // Create list option + listOption := attachment.ListOption{ + Page: page, + PageSize: pageSize, + Filters: filters, + OrderBy: orderBy, + Select: selectFields, + } + + // Get file list + result, err := manager.List(c.Request.Context(), listOption) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to list files: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Return the list result + response.RespondWithSuccess(c, response.StatusOK, result) +} + +// retrieve handles file metadata retrieval +func retrieve(c *gin.Context) { + uploaderID := c.Param("uploaderID") + fileID, _ := url.QueryUnescape(c.Param("fileID")) + + if uploaderID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + if fileID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get the attachment manager + manager, ok := attachment.Managers[uploaderID] + if !ok { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader not found: " + uploaderID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Get file info using the new Info method + fileInfo, err := manager.Info(c.Request.Context(), fileID) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File not found: " + err.Error(), + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Return the file info + response.RespondWithSuccess(c, response.StatusOK, fileInfo) +} + +// delete handles file deletion +func delete(c *gin.Context) { + uploaderID := c.Param("uploaderID") + fileID, _ := url.QueryUnescape(c.Param("fileID")) + + if uploaderID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + if fileID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get the attachment manager + manager, ok := attachment.Managers[uploaderID] + if !ok { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader not found: " + uploaderID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Check if file exists first + if !manager.Exists(c.Request.Context(), fileID) { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Delete the file + err := manager.Delete(c.Request.Context(), fileID) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to delete file: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + successData := gin.H{ + "message": "File deleted successfully", + "file_id": fileID, + } + response.RespondWithSuccess(c, response.StatusOK, successData) +} + +// content handles file content retrieval +func content(c *gin.Context) { + uploaderID := c.Param("uploaderID") + fileID, _ := url.QueryUnescape(c.Param("fileID")) + + if uploaderID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + if fileID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get the attachment manager + manager, ok := attachment.Managers[uploaderID] + if !ok { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader not found: " + uploaderID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Get file info first to obtain metadata + fileInfo, err := manager.Info(c.Request.Context(), fileID) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File not found: " + err.Error(), + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Read the file content + content, err := manager.Read(c.Request.Context(), fileID) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to read file: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Set headers based on file info + c.Header("Content-Type", fileInfo.ContentType) + if fileInfo.Filename != "" { + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileInfo.Filename)) + } + c.Header("Content-Length", fmt.Sprintf("%d", len(content))) + + // Return file content directly + c.Data(http.StatusOK, fileInfo.ContentType, content) +} + +// exists checks if a file exists +func exists(c *gin.Context) { + uploaderID := c.Param("uploaderID") + fileID, _ := url.QueryUnescape(c.Param("fileID")) + + if uploaderID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + if fileID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "File ID is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Get the attachment manager + manager, ok := attachment.Managers[uploaderID] + if !ok { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Uploader not found: " + uploaderID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Check if file exists + exists := manager.Exists(c.Request.Context(), fileID) + + successData := gin.H{ + "exists": exists, + "file_id": fileID, + } + response.RespondWithSuccess(c, response.StatusOK, successData) +} diff --git a/openapi/openapi.go b/openapi/openapi.go index 05768d7f..eda203ee 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -7,6 +7,7 @@ import ( "github.com/yaoapp/gou/application" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/openapi/dsl" + "github.com/yaoapp/yao/openapi/file" "github.com/yaoapp/yao/openapi/hello" "github.com/yaoapp/yao/openapi/kb" "github.com/yaoapp/yao/openapi/oauth" @@ -79,6 +80,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { // DSL handlers dsl.Attach(group.Group("/dsl"), openapi.OAuth) + // File handlers + file.Attach(group, openapi.OAuth) + // Knowledge Base handlers kb.Attach(group.Group("/kb"), openapi.OAuth) diff --git a/openapi/tests/file/file_test.go b/openapi/tests/file/file_test.go new file mode 100644 index 00000000..9511b9fc --- /dev/null +++ b/openapi/tests/file/file_test.go @@ -0,0 +1,1010 @@ +package openapi_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/attachment" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +const ( + testUploaderID = "test" + testFileName = "test-file.txt" + testFileContent = "This is a test file content for OpenAPI file management testing." + testContentType = "text/plain" + invalidUploaderID = "invalid-uploader" +) + +// setupTestUploader registers a test uploader for file operations +func setupTestUploader(t *testing.T) { + _, err := attachment.RegisterDefault(testUploaderID) + if err != nil { + t.Fatalf("Failed to register test uploader: %v", err) + } + t.Logf("Registered test uploader: %s", testUploaderID) +} + +// createMultipartRequest creates a multipart form request for file upload +func createMultipartRequest(url, fieldName, fileName string, content []byte, extraFields map[string]string) (*http.Request, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Create file part with explicit Content-Type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileName)) + h.Set("Content-Type", testContentType) + part, err := writer.CreatePart(h) + if err != nil { + return nil, err + } + + if _, err := part.Write(content); err != nil { + return nil, err + } + + // Add extra fields + for key, value := range extraFields { + if err := writer.WriteField(key, value); err != nil { + return nil, err + } + } + + if err := writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req, nil +} + +// createChunkedUploadRequest creates a request for chunked file upload +func createChunkedUploadRequest(url, fileName, uid string, chunkContent []byte, start, end, total int) (*http.Request, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Create file part + part, err := writer.CreateFormFile("file", fileName) + if err != nil { + return nil, err + } + + if _, err := part.Write(chunkContent); err != nil { + return nil, err + } + + if err := writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total)) + req.Header.Set("Content-Sync", "true") + req.Header.Set("Content-Uid", uid) + + return req, nil +} + +// TestFileUpload tests the file upload endpoint +func TestFileUpload(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Setup test uploader + setupTestUploader(t) + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "File Upload 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("UploadFileSuccess", func(t *testing.T) { + // Create multipart request + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ + "original_filename": testFileName, + "path": "documents/reports/quarterly-report.txt", + "groups": "documents,reports", + "client_id": "test-client", + "openid": "test-user", + }) + assert.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + // Make request + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + // Check response + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + + // Debug output for failed requests + if resp.StatusCode != http.StatusOK { + t.Logf("Expected status 200, got %d", resp.StatusCode) + t.Logf("Response body: %+v", response) + } + + assert.Contains(t, response, "file_id") + assert.Contains(t, response, "filename") + assert.Contains(t, response, "content_type") + assert.Contains(t, response, "path") + assert.Contains(t, response, "user_path") + assert.Equal(t, testFileName, response["filename"]) + assert.Equal(t, testContentType, response["content_type"]) + assert.Equal(t, "uploaded", response["status"]) + + // The file_id should be URL-safe (no slashes) and be an MD5 hash (32 chars) + fileID := response["file_id"].(string) + assert.NotContains(t, fileID, "/") // URL-safe ID should not contain slashes + assert.Len(t, fileID, 32) // MD5 hash is 32 characters + + t.Logf("Successfully uploaded file: %s (ID: %s)", testFileName, fileID) + }) + + t.Run("UploadFileWithCompression", func(t *testing.T) { + // Test with gzip compression + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ + "original_filename": testFileName, + "gzip": "true", + "compress_image": "true", + "compress_size": "1000", + }) + assert.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + 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 compressed file: %s", response["file_id"]) + }) + + t.Run("UploadFileInvalidUploader", func(t *testing.T) { + // Test with invalid uploader ID + requestURL := serverURL + baseURL + "/files/" + invalidUploaderID + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil) + assert.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + var errorResponse map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&errorResponse) + assert.NoError(t, err) + assert.Contains(t, errorResponse, "error") + + t.Logf("Correctly rejected upload with invalid uploader ID") + }) + + t.Run("UploadFileNoFile", func(t *testing.T) { + // Test with no file in request + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := http.NewRequest("POST", requestURL, strings.NewReader("no file data")) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var errorResponse map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&errorResponse) + assert.NoError(t, err) + assert.Contains(t, errorResponse, "error") + + t.Logf("Correctly rejected upload with no file") + }) + + t.Run("UploadFileMissingUploaderID", func(t *testing.T) { + // Test with missing uploader ID in path + requestURL := serverURL + baseURL + "/files/" + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil) + assert.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + // This should result in a 404 due to route mismatch + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + + t.Logf("Correctly handled request with missing uploader ID") + }) +} + +// TestFileChunkedUpload tests the chunked file upload feature +func TestFileChunkedUpload(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 Chunked Upload 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("ChunkedUploadSuccess", func(t *testing.T) { + requestURL := serverURL + baseURL + "/files/" + testUploaderID + uid := fmt.Sprintf("chunked-test-%d", time.Now().UnixNano()) + + // Split content into chunks + content := []byte(testFileContent) + chunkSize := 10 + totalSize := len(content) + + // Upload chunks + for i := 0; i < totalSize; i += chunkSize { + end := i + chunkSize + if end > totalSize { + end = totalSize + } + + chunk := content[i:end] + + req, err := createChunkedUploadRequest(requestURL, testFileName, uid, chunk, i, end-1, totalSize) + 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() + + if i+chunkSize >= totalSize { + // Last chunk should return success + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + + assert.Equal(t, "uploaded", response["status"]) + t.Logf("Successfully completed chunked upload: %s", response["file_id"]) + } else { + // Intermediate chunks should return partial content or OK + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent) + } + } + }) +} + +// TestFileList tests the file listing endpoint +func TestFileList(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 List 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") + + // First upload some test files + var uploadedFileIDs []string + for i := 0; i < 3; i++ { + fileName := fmt.Sprintf("test-file-%d.txt", i) + content := fmt.Sprintf("Test content for file %d", i) + + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := createMultipartRequest(requestURL, "file", fileName, []byte(content), map[string]string{ + "original_filename": fileName, + }) + 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() + + if resp.StatusCode == http.StatusOK { + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + + if fileID, ok := response["file_id"].(string); ok { + uploadedFileIDs = append(uploadedFileIDs, fileID) + } + } + } + + t.Run("ListFilesSuccess", func(t *testing.T) { + // Test basic file listing + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + 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, "files") + assert.Contains(t, response, "total") + assert.Contains(t, response, "page") + assert.Contains(t, response, "page_size") + + files, ok := response["files"].([]interface{}) + assert.True(t, ok) + t.Logf("Successfully listed %d files", len(files)) + }) + + t.Run("ListFilesWithPagination", func(t *testing.T) { + // Test with pagination parameters + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?page=1&page_size=2", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + 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.Equal(t, float64(1), response["page"]) + assert.Equal(t, float64(2), response["page_size"]) + t.Logf("Successfully listed files with pagination") + }) + + t.Run("ListFilesWithFilters", func(t *testing.T) { + // Test with filter parameters + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?status=uploaded&content_type=text/plain", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + t.Logf("Successfully listed files with filters") + }) + + t.Run("ListFilesInvalidUploader", func(t *testing.T) { + // Test with invalid uploader ID + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+invalidUploaderID, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + t.Logf("Correctly rejected list request with invalid uploader ID") + }) +} + +// TestFileRetrieve tests the file metadata retrieval endpoint +func TestFileRetrieve(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 Retrieve 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") + + var testFileID string + + t.Run("SetupUploadFile", func(t *testing.T) { + // Upload a file first + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ + "original_filename": testFileName, + }) + 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() + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + + testFileID = response["file_id"].(string) + t.Logf("Setup: Uploaded file with ID: %s", testFileID) + }) + + t.Run("RetrieveFileSuccess", func(t *testing.T) { + // Retrieve file metadata + encodedFileID := url.QueryEscape(testFileID) + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + 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") + assert.Contains(t, response, "filename") + assert.Contains(t, response, "content_type") + assert.Equal(t, testFileID, response["file_id"]) + assert.Equal(t, testFileName, response["filename"]) + assert.Equal(t, testContentType, response["content_type"]) + + t.Logf("Successfully retrieved file metadata: %s", testFileID) + }) + + t.Run("RetrieveFileNotFound", func(t *testing.T) { + // Test with non-existent file ID + nonExistentID := "non-existent-file-id" + encodedFileID := url.QueryEscape(nonExistentID) + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + var errorResponse map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&errorResponse) + assert.NoError(t, err) + assert.Contains(t, errorResponse, "error") + + t.Logf("Correctly handled non-existent file retrieval") + }) + + t.Run("RetrieveFileMissingIDs", func(t *testing.T) { + // Test with missing file ID - this URL actually matches the list endpoint + // which is correct RESTful behavior, so we expect 200 OK + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + // This URL actually matches the list endpoint, so we expect 200 OK + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify it's actually a list response (should have pagination structure) + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "files") + assert.Contains(t, response, "total") + assert.Contains(t, response, "page") + + t.Logf("URL without file ID correctly matched list endpoint") + }) +} + +// TestFileContent tests the file content retrieval endpoint +func TestFileContent(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 Content 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") + + var testFileID string + + t.Run("SetupUploadFile", func(t *testing.T) { + // Upload a file first + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ + "original_filename": testFileName, + }) + 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() + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + + testFileID = response["file_id"].(string) + t.Logf("Setup: Uploaded file with ID: %s", testFileID) + }) + + t.Run("GetFileContentSuccess", func(t *testing.T) { + // Get file content + encodedFileID := url.QueryEscape(testFileID) + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, testContentType, resp.Header.Get("Content-Type")) + + // Read and verify content + content, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Equal(t, testFileContent, string(content)) + + t.Logf("Successfully retrieved file content: %d bytes", len(content)) + }) + + t.Run("GetFileContentNotFound", func(t *testing.T) { + // Test with non-existent file ID + nonExistentID := "non-existent-file-id" + encodedFileID := url.QueryEscape(nonExistentID) + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + t.Logf("Correctly handled non-existent file content request") + }) +} + +// TestFileExists tests the file existence check endpoint +func TestFileExists(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 Exists 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") + + var testFileID string + + t.Run("SetupUploadFile", func(t *testing.T) { + // Upload a file first + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ + "original_filename": testFileName, + }) + 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() + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + + testFileID = response["file_id"].(string) + t.Logf("Setup: Uploaded file with ID: %s", testFileID) + }) + + t.Run("FileExistsTrue", func(t *testing.T) { + // Check if uploaded file exists + encodedFileID := url.QueryEscape(testFileID) + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + 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, "exists") + assert.Contains(t, response, "file_id") + assert.Equal(t, testFileID, response["file_id"]) + assert.Equal(t, true, response["exists"]) + + t.Logf("File exists check returned true for: %s", testFileID) + }) + + t.Run("FileExistsFalse", func(t *testing.T) { + // Check if non-existent file exists + nonExistentID := "non-existent-file-id" + encodedFileID := url.QueryEscape(nonExistentID) + req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + 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, "exists") + assert.Contains(t, response, "file_id") + assert.Equal(t, nonExistentID, response["file_id"]) + assert.Equal(t, false, response["exists"]) + + t.Logf("File exists check returned false for: %s", nonExistentID) + }) +} + +// TestFileDelete tests the file deletion endpoint +func TestFileDelete(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 Delete 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("DeleteFileSuccess", func(t *testing.T) { + // Upload a file first + requestURL := serverURL + baseURL + "/files/" + testUploaderID + req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ + "original_filename": testFileName, + }) + 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() + + var uploadResponse map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&uploadResponse) + assert.NoError(t, err) + + testFileID := uploadResponse["file_id"].(string) + + // Now delete the file + encodedFileID := url.QueryEscape(testFileID) + deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + assert.NoError(t, err) + deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + deleteResp, err := http.DefaultClient.Do(deleteReq) + assert.NoError(t, err) + assert.NotNil(t, deleteResp) + defer deleteResp.Body.Close() + + assert.Equal(t, http.StatusOK, deleteResp.StatusCode) + + var deleteResponse map[string]interface{} + err = json.NewDecoder(deleteResp.Body).Decode(&deleteResponse) + assert.NoError(t, err) + + assert.Contains(t, deleteResponse, "message") + assert.Contains(t, deleteResponse, "file_id") + assert.Equal(t, testFileID, deleteResponse["file_id"]) + + t.Logf("Successfully deleted file: %s", testFileID) + }) + + t.Run("DeleteFileNotFound", func(t *testing.T) { + // Test deleting non-existent file + nonExistentID := "non-existent-file-id" + encodedFileID := url.QueryEscape(nonExistentID) + req, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + var errorResponse map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&errorResponse) + assert.NoError(t, err) + assert.Contains(t, errorResponse, "error") + + t.Logf("Correctly handled deletion of non-existent file") + }) +} + +// TestFileEndpointsUnauthorized tests that endpoints return 401 when not authenticated +func TestFileEndpointsUnauthorized(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 + } + + endpoints := []struct { + method string + path string + }{ + {"POST", "/files/" + testUploaderID}, + {"GET", "/files/" + testUploaderID}, + {"GET", "/files/" + testUploaderID + "/test-file-id"}, + {"DELETE", "/files/" + testUploaderID + "/test-file-id"}, + {"GET", "/files/" + testUploaderID + "/test-file-id/content"}, + {"GET", "/files/" + testUploaderID + "/test-file-id/exists"}, + } + + for _, endpoint := range endpoints { + t.Run(fmt.Sprintf("Unauthorized_%s_%s", endpoint.method, strings.ReplaceAll(endpoint.path, "/", "_")), func(t *testing.T) { + var req *http.Request + var err error + + if endpoint.method == "POST" { + // For POST, create a simple multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("test", "data") + writer.Close() + + req, err = http.NewRequest(endpoint.method, serverURL+baseURL+endpoint.path, body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + } else { + req, err = http.NewRequest(endpoint.method, serverURL+baseURL+endpoint.path, nil) + } + assert.NoError(t, err) + + // No Authorization header + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + t.Logf("Correctly rejected unauthorized request to %s %s", endpoint.method, endpoint.path) + }) + } +} + +// TestFileIntegration tests the full file lifecycle +func TestFileIntegration(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 Integration 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("FullFileLifecycle", func(t *testing.T) { + // Step 1: Upload a file + requestURL := serverURL + baseURL + "/files/" + testUploaderID + uploadReq, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ + "original_filename": testFileName, + "path": "integration/test/file.txt", + "groups": "integration,test", + }) + assert.NoError(t, err) + uploadReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + uploadResp, err := http.DefaultClient.Do(uploadReq) + assert.NoError(t, err) + defer uploadResp.Body.Close() + + assert.Equal(t, http.StatusOK, uploadResp.StatusCode) + + var uploadResponse map[string]interface{} + err = json.NewDecoder(uploadResp.Body).Decode(&uploadResponse) + assert.NoError(t, err) + + testFileID := uploadResponse["file_id"].(string) + + // Step 2: Verify file exists + encodedFileID := url.QueryEscape(testFileID) + existsReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + assert.NoError(t, err) + existsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + existsResp, err := http.DefaultClient.Do(existsReq) + assert.NoError(t, err) + defer existsResp.Body.Close() + + assert.Equal(t, http.StatusOK, existsResp.StatusCode) + + // Step 3: Retrieve file metadata + retrieveReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + assert.NoError(t, err) + retrieveReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + retrieveResp, err := http.DefaultClient.Do(retrieveReq) + assert.NoError(t, err) + defer retrieveResp.Body.Close() + + assert.Equal(t, http.StatusOK, retrieveResp.StatusCode) + + // Step 4: Download file content + contentReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) + assert.NoError(t, err) + contentReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + contentResp, err := http.DefaultClient.Do(contentReq) + assert.NoError(t, err) + defer contentResp.Body.Close() + + assert.Equal(t, http.StatusOK, contentResp.StatusCode) + + content, err := io.ReadAll(contentResp.Body) + assert.NoError(t, err) + assert.Equal(t, testFileContent, string(content)) + + // Step 5: List files and verify our file is included + listReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?name="+testFileName, nil) + assert.NoError(t, err) + listReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + listResp, err := http.DefaultClient.Do(listReq) + assert.NoError(t, err) + defer listResp.Body.Close() + + assert.Equal(t, http.StatusOK, listResp.StatusCode) + + // Step 6: Delete the file + deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) + assert.NoError(t, err) + deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + deleteResp, err := http.DefaultClient.Do(deleteReq) + assert.NoError(t, err) + defer deleteResp.Body.Close() + + assert.Equal(t, http.StatusOK, deleteResp.StatusCode) + + // Step 7: Verify file no longer exists + finalExistsReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) + assert.NoError(t, err) + finalExistsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + finalExistsResp, err := http.DefaultClient.Do(finalExistsReq) + assert.NoError(t, err) + defer finalExistsResp.Body.Close() + + assert.Equal(t, http.StatusOK, finalExistsResp.StatusCode) + + var finalExistsResponse map[string]interface{} + err = json.NewDecoder(finalExistsResp.Body).Decode(&finalExistsResponse) + assert.NoError(t, err) + assert.Equal(t, false, finalExistsResponse["exists"]) + + t.Logf("Completed full file lifecycle test for: %s", testFileID) + }) +} diff --git a/yao/models/attachment.mod.yao b/yao/models/attachment.mod.yao index 3eed8682..a54b9b6f 100644 --- a/yao/models/attachment.mod.yao +++ b/yao/models/attachment.mod.yao @@ -28,27 +28,10 @@ "index": true }, { - "name": "uid", + "name": "uploader", "type": "string", - "label": "User ID", - "comment": "User identifier", - "length": 255, - "nullable": false, - "index": true - }, - { - "name": "guest", - "type": "boolean", - "label": "Guest", - "comment": "Whether uploaded by guest user", - "default": false, - "index": true - }, - { - "name": "manager", - "type": "string", - "label": "Manager", - "comment": "File manager type", + "label": "Uploader", + "comment": "File uploader type", "length": 200, "nullable": false, "index": true @@ -72,18 +55,55 @@ "index": true }, { - "name": "public", - "type": "boolean", - "label": "Public", - "comment": "Whether file is public", - "default": false, + "name": "url", + "type": "string", + "label": "URL", + "comment": "File URL", + "length": 1000, + "nullable": true, + "index": false + }, + { + "name": "description", + "type": "string", + "label": "Description", + "comment": "File description", + "length": 1000, + "nullable": true, + "index": false + }, + { + "name": "type", + "type": "string", + "label": "Type", + "comment": "File type", + "length": 200, + "nullable": true, "index": true }, { - "name": "scope", + "name": "user_path", + "type": "string", + "label": "User Path", + "comment": "User-specified complete file path", + "length": 1000, + "nullable": true, + "index": true + }, + { + "name": "path", + "type": "string", + "label": "Storage Path", + "comment": "Actual storage path for the file", + "length": 1000, + "nullable": false, + "index": true + }, + { + "name": "groups", "type": "json", - "label": "Scope", - "comment": "File access scope", + "label": "Groups", + "comment": "File groups", "nullable": true }, { @@ -102,15 +122,6 @@ "nullable": false, "index": true }, - { - "name": "collection_id", - "type": "string", - "label": "Collection ID", - "comment": "Knowledge collection identifier", - "length": 200, - "nullable": true, - "index": true - }, { "name": "status", "type": "enum", @@ -142,6 +153,24 @@ "comment": "Error information", "length": 600, "nullable": true + }, + { + "name": "client_id", + "type": "string", + "label": "Client ID", + "comment": "Client identifier", + "length": 255, + "nullable": true, + "index": true + }, + { + "name": "openid", + "type": "string", + "label": "OpenID", + "comment": "OpenID identifier", + "length": 255, + "nullable": true, + "index": true } ], "relations": {},