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:
parent
0fe42b517f
commit
070ff59225
14 changed files with 3390 additions and 260 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
206
data/bindata.go
206
data/bindata.go
|
|
@ -280,7 +280,7 @@ func cuiSetupIndexHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -300,7 +300,7 @@ func cuiV09IndexHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -320,7 +320,7 @@ func cuiV10IndexHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -340,7 +340,7 @@ func cuiV10Layouts__indexAsyncJs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -360,7 +360,7 @@ func cuiV10UmiJs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -380,7 +380,7 @@ func initEnv() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -400,7 +400,7 @@ func initVscodeSettingsJson() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -420,7 +420,7 @@ func initVscodeTypesRuntimeConsoleDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -440,7 +440,7 @@ func initVscodeTypesRuntimeExceptionDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -460,7 +460,7 @@ func initVscodeTypesRuntimeFsDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -480,7 +480,7 @@ func initVscodeTypesRuntimeGlobalDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -500,7 +500,7 @@ func initVscodeTypesRuntimeHttpDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -520,7 +520,7 @@ func initVscodeTypesRuntimeIoDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -540,7 +540,7 @@ func initVscodeTypesRuntimeLogDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -560,7 +560,7 @@ func initVscodeTypesRuntimeNeoDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -580,7 +580,7 @@ func initVscodeTypesRuntimeProcessFsDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -600,7 +600,7 @@ func initVscodeTypesRuntimeProcessHttpDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -620,7 +620,7 @@ func initVscodeTypesRuntimeProcessModelDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -640,7 +640,7 @@ func initVscodeTypesRuntimeProcessDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -660,7 +660,7 @@ func initVscodeTypesRuntimeQueryDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -680,7 +680,7 @@ func initVscodeTypesRuntimeStoreDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -700,7 +700,7 @@ func initVscodeTypesRuntimeSuiDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -720,7 +720,7 @@ func initVscodeTypesRuntimeTimeDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -740,7 +740,7 @@ func initVscodeTypesRuntimeDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -760,7 +760,7 @@ func initVscodeTypesSuiDTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -780,7 +780,7 @@ func initAppYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -800,7 +800,7 @@ func initDataReadmeMd() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -820,7 +820,7 @@ func initDataTemplatesDefault__assetsReadmeMd() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -840,7 +840,7 @@ func initDataTemplatesDefault__assetsImagesIconsAppPng() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -860,7 +860,7 @@ func initDataTemplatesDefault__assetsImagesLogosLogo_colorSvg() (*asset, error)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -880,7 +880,7 @@ func initDataTemplatesDefault__assetsImagesLogosWordmarkSvg() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -900,7 +900,7 @@ func initDataTemplatesDefault__dataJson() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -920,7 +920,7 @@ func initDataTemplatesDefault__documentHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -940,7 +940,7 @@ func initDataTemplatesDefaultIndexIndexCss() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -960,7 +960,7 @@ func initDataTemplatesDefaultIndexIndexHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -980,7 +980,7 @@ func initDataTemplatesDefaultIndexIndexJson() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1000,7 +1000,7 @@ func initDbReadmeMd() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1020,7 +1020,7 @@ func initFlowsMenuFlowYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1040,7 +1040,7 @@ func initFormsAccountFormYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1060,7 +1060,7 @@ func initIconsAppIcns() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1080,7 +1080,7 @@ func initIconsAppIco() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1100,7 +1100,7 @@ func initIconsAppPng() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1120,7 +1120,7 @@ func initLoginsAdminLoginYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1140,7 +1140,7 @@ func initLogsReadmeMd() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1160,7 +1160,7 @@ func initModelsAdminUserModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1180,7 +1180,7 @@ func initModelsTestsPetModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1200,7 +1200,7 @@ func initNeoNeoYml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1220,7 +1220,7 @@ func initPublicReadmeMd() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1240,7 +1240,7 @@ func initPublicAssetsReadmeMd() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1260,7 +1260,7 @@ func initPublicAssetsImagesIconsAppPng() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1280,7 +1280,7 @@ func initPublicAssetsImagesLogosLogo_colorSvg() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1300,7 +1300,7 @@ func initPublicAssetsImagesLogosWordmarkSvg() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1320,7 +1320,7 @@ func initPublicAssetsLibsuiMinJs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1340,7 +1340,7 @@ func initPublicAssetsLibsuiMinJsMap() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1360,7 +1360,7 @@ func initPublicIndexCfg() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1380,7 +1380,7 @@ func initPublicIndexSui() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1400,7 +1400,7 @@ func initScriptsAccountTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1420,7 +1420,7 @@ func initScriptsAiNeoTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1440,7 +1440,7 @@ func initScriptsTestsTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1460,7 +1460,7 @@ func initScriptsUtilsTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1480,7 +1480,7 @@ func initSuisWebSuiYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1500,7 +1500,7 @@ func initTablesAccountTabYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1520,7 +1520,7 @@ func initTsconfigJson() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1540,7 +1540,7 @@ func libsuiAgentTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "libsui/agent.ts", size: 15267, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "libsui/agent.ts", size: 15267, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1560,7 +1560,7 @@ func libsuiIndexTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "libsui/index.ts", size: 13049, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "libsui/index.ts", size: 13049, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1580,7 +1580,7 @@ func libsuiUtilsTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1600,7 +1600,7 @@ func libsuiYaoTs() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1620,7 +1620,7 @@ func publicIndexHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1640,7 +1640,7 @@ func uiIndexHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1660,7 +1660,7 @@ func yaoDataIcons404Png() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1680,7 +1680,7 @@ func yaoDataIconsIconIcns() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1700,7 +1700,7 @@ func yaoDataIconsIconIco() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1720,7 +1720,7 @@ func yaoDataIconsIconPng() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1740,7 +1740,7 @@ func yaoDataIndexHtml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1760,7 +1760,7 @@ func yaoFieldsModelTransJson() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1780,7 +1780,7 @@ func yaoLangsEnUsJson() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1800,7 +1800,7 @@ func yaoLangsZhCnGlobalYml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1820,7 +1820,7 @@ func yaoLangsZhCnLoginsAdminLoginYml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1840,7 +1840,7 @@ func yaoLangsZhCnLoginsUserLoginYml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1860,7 +1860,7 @@ func yaoLangsZhHkGlobalYml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1880,7 +1880,7 @@ func yaoLangsZhHkLoginsAdminLoginYml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1900,7 +1900,7 @@ func yaoLangsZhHkLoginsUserLoginYml() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1920,12 +1920,12 @@ func yaoModelsAssistantModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/assistant.mod.yao", size: 4114, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/assistant.mod.yao", size: 4114, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
var _yaoModelsAttachmentModYao = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x56\x4d\x6f\xdb\x38\x10\xbd\xfb\x57\x0c\x78\xce\x21\x58\x20\x0b\xd8\xb7\xdd\xec\xb6\x08\x8a\x16\x01\xda\xa0\x87\x20\x30\x28\x69\x24\xb3\xa0\x48\x95\x1c\x22\x75\x02\xff\xf7\x82\xb4\x2c\x53\x0c\x6d\x47\x89\xd1\x93\xe1\xf9\x78\x7c\xf3\x38\x9a\xe1\xf3\x0c\x80\x29\xde\x22\x5b\x00\xe3\x44\xbc\x5c\xb5\xa8\x88\x5d\x78\xbb\xe4\x05\x4a\xef\xf8\x27\x71\x54\x68\x4b\x23\x3a\x12\x5a\x8d\xdd\x40\xbc\x90\x08\xb5\x36\x60\x49\x1b\xa1\x1a\xa8\x85\x44\xd8\x23\x5b\x78\x14\xb4\x82\x16\x89\x57\x9c\x38\x70\x55\x01\x2f\x4b\xb4\x16\x4a\xad\xc8\x68\xb9\x3d\x82\x78\x63\xd9\x02\xee\x99\x5d\x5b\xc2\x96\x3d\x04\x6b\xe1\x84\x24\xe1\x0f\x25\xe3\x30\x98\x0c\xf2\x4a\x2b\xb9\x8e\x6d\x56\x1b\x62\x0b\x98\xcf\xe7\xf3\x1e\xac\x90\xbe\x42\x5f\xed\xe1\x7a\x01\x58\xa9\xdb\xf0\x37\x53\x14\x9b\x01\x6c\x02\x5a\xa9\xa5\x6b\x55\x60\x17\xb2\xb6\xa8\x11\xae\xa8\x7a\x3c\x7f\xf4\xba\x0b\xb6\x9b\xff\xf6\xb6\x8c\xae\x10\xfb\x23\x16\x77\x4a\xfc\x74\xb1\x7e\x20\x2a\x54\x24\x6a\x81\x86\x85\xf8\xcd\x45\x9e\x84\xd7\x7d\x99\x63\x62\xc9\xdf\x4b\x86\xcd\x07\x7f\x53\x07\x78\x04\x5f\x74\xf4\x3e\x1b\x55\x43\x2b\xb6\x80\xbf\xae\xae\x06\xa3\x72\x52\xf6\x92\xd7\x5c\x5a\x1c\x1c\x2e\x94\x13\x5d\x55\xb0\x0a\x55\xe1\xaf\xde\x78\xb4\x26\x37\xa9\x9e\x3b\x8b\xe6\xa0\xae\xde\xf7\xfe\x7a\x5e\xcd\xbc\x71\x68\xe9\x25\xf7\x42\x6b\x89\x5c\x65\xc8\x7f\x1c\x27\x44\xd4\xbf\xaf\x90\x56\x68\xc0\x75\x52\xf3\x0a\x2b\x28\xd6\x10\xe0\xc1\xd9\xb8\x92\x0a\x6b\xee\x24\xbd\x9d\x73\xcb\x15\x6f\x62\xc4\x93\x8a\x7f\x4e\x33\xd2\x0e\xea\x21\x21\x20\x65\x34\xbf\xbc\x3c\xa3\xe6\x7e\x9e\xa0\xa2\xe5\xf8\xb0\x93\x45\x5c\x6f\xd3\xe0\xdb\x28\x2d\xad\xa4\x07\xff\x33\x95\x84\xdf\xd7\x57\xf0\x65\x14\x9e\x32\x1f\x83\x0d\x8c\xaf\xce\xca\xb8\x73\x85\x14\xe5\x94\x86\xbf\x4d\x32\x32\x1d\x1f\x56\x89\xb0\x90\x82\xbf\xbb\xd3\x6d\xa9\x73\x2d\xf2\xc3\xea\x1c\xd3\xaf\xe3\xe8\x54\xdf\x7e\x9b\x25\x98\x91\xa4\xa7\x87\xc5\x93\xe8\x26\xcd\x8a\x51\xfc\x11\xe1\x3c\x70\x87\xd5\x19\x95\x2b\xd6\x84\x36\xc3\x55\x34\x37\x8a\x70\x34\x0b\x06\xba\xff\x8e\x73\x52\xfd\xac\x78\x42\x10\x0a\x12\xe8\x73\xcc\x03\x29\xb1\xf4\x8f\x96\x69\x7b\xf1\x7a\xc8\x3b\xb4\x4d\x3e\x29\xfd\x28\xb1\x6a\xfc\x58\x18\x62\x8f\x6f\x97\xfc\xd7\xf6\xb6\xb5\x68\x89\x93\xcb\xdc\x02\x2a\xd7\xe6\xfa\x37\x09\x4f\x2f\xa0\x33\xda\x77\xb0\x7f\xbc\xa5\xc8\x7a\xf7\xe6\xbb\xef\x2d\x7e\xa7\x87\x3d\x14\x4b\x37\x18\xa3\x56\xdb\xd5\x93\xc4\x05\xdb\x38\x6c\x9b\xba\xac\xb9\x90\x99\xfc\x9d\xbd\x37\x3f\x64\x5a\x39\xc3\x68\xc2\xe0\x32\xba\x31\x68\x33\x6a\x1e\xec\x8f\xdb\x17\x29\x91\xa2\xb7\x7b\x31\x77\xd0\x20\x54\xad\x4d\xcb\x83\x94\x93\x5a\xe3\x28\x73\x34\x46\x4f\x59\xd6\xff\x8f\xe3\x23\xce\xc1\x73\x82\xe5\xdf\xa7\x58\xce\xfa\xcb\x61\x06\x65\x00\xf1\x0f\xe6\xe7\xed\x0b\x7a\x7b\xeb\xe1\x05\xbd\x8d\x19\xfa\xea\x19\x18\x89\x16\x2d\xf1\xb6\xb3\xbb\x0f\xc2\x3f\xe8\x6b\x5a\x56\x28\x91\x42\x56\x18\x00\xb0\x99\x6d\x66\xbf\x03\x00\x00\xff\xff\xaf\xc4\x06\x40\xc0\x0c\x00\x00")
|
||||
var _yaoModelsAttachmentModYao = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x57\x5f\x6f\xd3\x3e\x14\x7d\xef\xa7\xb8\xca\xf3\x7e\xd2\x7e\x48\x43\x6a\xdf\xc6\x06\x68\x12\x82\x09\x98\x78\x98\xa6\xca\x4d\x6e\x52\x23\xc7\x0e\xf6\x8d\xa0\x9b\xfa\xdd\x91\x9d\xa4\xb5\x53\xa7\x5d\x02\xe3\xa9\xea\xfd\x73\x7c\xce\xf5\xb5\x7d\xf3\x34\x03\x48\x24\x2b\x31\x59\x40\xc2\x88\x58\xba\x2e\x51\x52\x72\x66\xed\x82\xad\x50\x58\xc7\x65\xcf\x91\xa1\x49\x35\xaf\x88\x2b\x19\xba\x81\xd8\x4a\x20\xe4\x4a\x83\x21\xa5\xb9\x2c\x20\xe7\x02\x61\x8f\x6c\xe0\x27\xa7\x35\x94\x48\x2c\x63\xc4\x80\xc9\x0c\x58\x9a\xa2\x31\x90\x2a\x49\x5a\x89\x66\x09\x62\x85\x49\x16\x70\x9f\x98\x8d\x21\x2c\x93\x07\x67\x5d\xd5\x5c\x10\xb7\x8b\x92\xae\xd1\x99\x34\xb2\x4c\x49\xb1\xf1\x6d\x46\x69\x4a\x16\x30\x9f\xcf\xe7\x2d\xd8\x4a\x58\x85\x56\xed\xb0\x5e\x80\x24\x55\xa5\xfb\x1b\x11\x95\xcc\x00\xb6\x0e\x2d\x55\xa2\x2e\xa5\x63\xe7\xb2\x1a\x54\x0f\x97\x67\x2d\x9e\x5d\x7a\x53\x39\xdb\xcd\xf5\xde\x16\xa9\x2b\xf8\x7e\x8f\xc5\x9d\xe4\x3f\x6a\xbf\x7e\xc0\x33\x94\xc4\x73\x8e\x3a\x71\xf1\xdb\xb3\x38\x09\x5b\xf7\x65\x8c\x89\x21\xbb\x2f\x11\x36\xef\xec\x4e\x0d\xf0\x70\x3e\x6f\xe9\x7d\x36\xca\x82\xd6\xc9\x02\x5e\x5d\x5c\xec\x8c\xb2\x16\xa2\x2d\x79\xce\x84\xc1\x9d\xa3\x76\x72\xbc\xad\x72\x56\x2e\x33\xfc\xd5\x1a\x8f\x6a\xaa\x2b\xa1\x58\xe6\x2f\x7f\x52\xd4\xdd\x41\x4a\x5f\x55\x07\x0a\x0e\x2b\x22\xec\xfc\xfc\xb4\xb0\x67\x4b\xb0\x4d\x8e\x92\x96\xe1\x62\x27\x65\x5c\x35\x69\xf0\x35\x48\xeb\x4b\x69\xc1\xff\x8d\x12\xf7\xfb\x7c\x05\x1f\x83\xf0\x3e\xf3\x10\x6c\xc7\xf8\xe2\xaf\x32\xae\xb5\x18\xd3\x39\x9f\x3f\x0c\xf3\x0d\x9c\x3b\xba\xff\x9f\xc7\xf9\x46\xbb\xdd\x89\x38\xca\xd7\xbf\x66\x9f\xcf\xfb\x3a\x96\xd5\xe7\x1f\x85\x7e\x29\x1d\x23\x7b\xfd\x78\x8f\x8f\xeb\xed\x89\xf7\x8c\x41\xbd\xac\x18\xad\xc7\xb4\x8b\x41\x0d\xb7\x41\x8e\x7f\x8f\x1b\xd4\xff\x99\x0a\x53\x7b\x7d\x66\x90\xaa\xb2\x12\x48\xd8\xbc\x8e\xe1\x4a\xd3\x76\xe1\xa4\xa6\x91\x72\xbe\x90\xd2\xac\xc0\x61\x45\x97\x29\xd5\x4c\xb8\x67\xde\xc6\x59\x78\xf7\xee\xd3\xba\x51\x35\x42\xd0\xc4\xe3\x5c\x68\x55\x57\xe6\x50\xd3\x77\x13\x34\x75\xa7\xe8\x7d\x2f\xbc\xdf\x58\x7d\xb8\x5e\xc5\x8f\x53\x79\xe4\xd5\x21\x91\x95\x52\x02\x59\x94\x4b\x10\xef\x31\xf9\xb6\x46\x5a\xa3\x6e\xfa\x82\x1b\xb0\xc0\x15\x7a\xaf\x78\x86\x39\xab\x05\x4d\xaf\xda\x6a\x43\x18\x29\xda\x8a\x17\x37\x92\xb0\x08\xde\xf6\x8e\xee\x9b\x30\xa7\x5f\x39\xc3\x1f\x11\xb8\x84\x1e\xf4\x9f\xef\xb0\x21\x46\x75\x84\x2c\xca\xba\x8c\xf6\x6c\x18\xde\xe7\x59\x69\x65\x07\x4d\x3b\x95\xf6\x91\x55\x37\xcc\xde\xb7\x16\xe8\xa6\x0d\xff\x78\xec\x8c\xde\x8e\x74\x7a\x7a\x71\xce\x16\x86\x35\xa9\xcb\x9c\x71\x11\xc9\xef\xec\xad\xf9\x21\xb2\xe3\x11\x46\x23\xce\xbf\x56\x85\x46\x13\xa9\xe6\xe0\x1d\x70\x7b\x90\xe2\x55\xf4\x76\x5f\xcc\x0e\x1a\xb8\xcc\x95\x2e\xd9\xc0\xab\x72\xe4\x8a\x3e\xca\x1c\xb5\x56\x63\x46\xbe\xb7\x61\xbc\xc7\xd9\x79\x4e\xb0\x7c\x3d\x91\x65\x2a\xb8\x9d\xeb\x46\x4d\xdc\x57\x2e\x67\x68\xe6\x6e\xbd\x53\xa6\xee\x69\x4f\x84\xaa\x50\x8e\xe2\xff\xa9\x42\x39\x40\xbe\x71\xbd\x04\xf9\x59\x7b\x38\x12\x8d\xc2\x6d\xa2\xfd\x12\x7b\x6a\x3e\xcd\x9a\x53\xe7\x3e\xcd\x9a\x98\xdd\xb9\x7e\x82\x84\x78\x89\x86\x58\x59\x99\x6e\x11\xfb\xa5\x98\xd3\x32\x43\xfb\x16\x9b\xee\x9e\x82\xed\x6c\x3b\xfb\x1d\x00\x00\xff\xff\x6d\x48\xa5\x86\x19\x0f\x00\x00")
|
||||
|
||||
func yaoModelsAttachmentModYaoBytes() ([]byte, error) {
|
||||
return bindataRead(
|
||||
|
|
@ -1940,7 +1940,7 @@ func yaoModelsAttachmentModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 3264, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 3865, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1960,7 +1960,7 @@ func yaoModelsAuditModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -1980,7 +1980,7 @@ func yaoModelsChatModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/chat.mod.yao", size: 1444, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/chat.mod.yao", size: 1444, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2000,7 +2000,7 @@ func yaoModelsConfigModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2020,7 +2020,7 @@ func yaoModelsDslModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3806, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3806, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2040,7 +2040,7 @@ func yaoModelsHistoryModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/history.mod.yao", size: 2902, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/history.mod.yao", size: 2902, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2060,7 +2060,7 @@ func yaoModelsKbModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/kb.mod.yao", size: 2881, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/kb.mod.yao", size: 2881, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2080,7 +2080,7 @@ func yaoModelsUserModYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 6775, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 6775, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2100,7 +2100,7 @@ func yaoReleaseAppYaz() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2120,7 +2120,7 @@ func yaoStoresAgentCacheLruYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2140,7 +2140,7 @@ func yaoStoresAgentMemoryBadgerYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/agent/memory.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/agent/memory.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2160,7 +2160,7 @@ func yaoStoresCacheLruYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2180,7 +2180,7 @@ func yaoStoresKbCacheLruYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2200,7 +2200,7 @@ func yaoStoresKbStoreBadgerYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/kb/store.badger.yao", size: 349, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/kb/store.badger.yao", size: 349, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2220,7 +2220,7 @@ func yaoStoresOauthCacheLruYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2240,7 +2240,7 @@ func yaoStoresOauthClientBadgerYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/oauth/client.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/oauth/client.badger.yao", size: 352, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2260,7 +2260,7 @@ func yaoStoresOauthStoreBadgerYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/oauth/store.badger.yao", size: 376, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/oauth/store.badger.yao", size: 376, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2280,7 +2280,7 @@ func yaoStoresStoreBadgerYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/stores/store.badger.yao", size: 341, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/stores/store.badger.yao", size: 341, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -2300,7 +2300,7 @@ func yaoUploadersAttachmentLocalYao() (*asset, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1131, mode: os.FileMode(420), modTime: time.Unix(1753437622, 0)}
|
||||
info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1131, mode: os.FileMode(420), modTime: time.Unix(1753527321, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
525
openapi/file/README.md
Normal 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
470
openapi/file/file.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
1010
openapi/tests/file/file_test.go
Normal file
1010
openapi/tests/file/file_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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": {},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue