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.
This commit is contained in:
Max 2025-07-26 19:25:41 +08:00
parent 0fe42b517f
commit 070ff59225
14 changed files with 3390 additions and 260 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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`.

525
openapi/file/README.md Normal file
View file

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

470
openapi/file/file.go Normal file
View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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": {},