- 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.
470 lines
13 KiB
Go
470 lines
13 KiB
Go
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)
|
|
}
|