- Restructure handlers into file_commands/file_queries/file_handlers - Add gRPC auth client, JWT middleware, rate limiting, request ID - Add common utilities: logger, sanitizer, s3_errors - Add unit tests for config, mediator, auth, request_id, sanitize - Add proto definitions and generated code - Remove old web UI pages - Add .dockerignore and .env.example
443 lines
14 KiB
Go
443 lines
14 KiB
Go
package endpoints
|
|
|
|
import (
|
|
"rag/file-system/internal/api/handlers"
|
|
"rag/file-system/internal/api/requests"
|
|
"rag/file-system/internal/api/validators"
|
|
"rag/file-system/internal/common"
|
|
"rag/file-system/internal/domain/repository"
|
|
"rag/file-system/internal/infrastructure/mediator"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type FileEndpoint struct {
|
|
Mediator *mediator.Mediator
|
|
FileValidator *validators.FileValidator
|
|
}
|
|
|
|
func NewFileEndpoint(m *mediator.Mediator, fv *validators.FileValidator) *FileEndpoint {
|
|
return &FileEndpoint{
|
|
Mediator: m,
|
|
FileValidator: fv,
|
|
}
|
|
}
|
|
|
|
// UploadFile godoc
|
|
// @Summary Upload file
|
|
// @Description Upload a small file to the specified bucket
|
|
// @Tags Files
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param bucket_name formData string true "Bucket name"
|
|
// @Param file formData file true "File to upload"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/upload [post]
|
|
func (e *FileEndpoint) UploadFile(c *gin.Context) {
|
|
var req requests.UploadFileRequest
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := e.FileValidator.ValidateUpload(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
file, err := req.File.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
cmd := handlers.UploadFileCommand{
|
|
BucketName: req.BucketName,
|
|
FileName: req.File.Filename,
|
|
Data: file,
|
|
}
|
|
|
|
result, err := mediator.Send[handlers.UploadFileCommand, string](e.Mediator, c.Request.Context(), cmd)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": result})
|
|
}
|
|
|
|
// DownloadFile godoc
|
|
// @Summary Download file
|
|
// @Description Download a file from the specified bucket
|
|
// @Tags Files
|
|
// @Accept json
|
|
// @Produce octet-stream
|
|
// @Security ApiKeyAuth
|
|
// @Param bucket_name query string true "Bucket name"
|
|
// @Param object_key query string true "Object key"
|
|
// @Success 200 {file} file
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/download [get]
|
|
func (e *FileEndpoint) DownloadFile(c *gin.Context) {
|
|
var req requests.DownloadFileRequest
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := e.FileValidator.ValidateDownload(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
query := handlers.DownloadFileQuery{
|
|
BucketName: req.BucketName,
|
|
ObjectKey: req.ObjectKey,
|
|
}
|
|
|
|
result, err := mediator.Send[handlers.DownloadFileQuery, io.ReadCloser](e.Mediator, c.Request.Context(), query)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
defer result.Close()
|
|
|
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, common.SanitizeFilename(req.ObjectKey)))
|
|
c.Header("Content-Type", "application/octet-stream")
|
|
io.Copy(c.Writer, result)
|
|
}
|
|
|
|
// ListFiles godoc
|
|
// @Summary List files (paginated)
|
|
// @Description List files in a bucket with pagination and prefix filtering
|
|
// @Tags Files
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param bucket_name query string true "Bucket name"
|
|
// @Param prefix query string false "File name prefix filter"
|
|
// @Param max_keys query int false "Items per page (default 20)"
|
|
// @Param token query string false "Pagination token"
|
|
// @Success 200 {object} repository.ListFilesResult
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/list [get]
|
|
func (e *FileEndpoint) ListFiles(c *gin.Context) {
|
|
var req requests.ListFilesRequest
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := e.FileValidator.ValidateListFiles(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var token *string
|
|
if req.Token != "" {
|
|
token = &req.Token
|
|
}
|
|
|
|
query := handlers.ListFilesQuery{
|
|
BucketName: req.BucketName,
|
|
Prefix: req.Prefix,
|
|
MaxKeys: req.MaxKeys,
|
|
Token: token,
|
|
}
|
|
|
|
result, err := mediator.Send[handlers.ListFilesQuery, *repository.ListFilesResult](e.Mediator, c.Request.Context(), query)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// GetPreviewURL godoc
|
|
// @Summary Get preview URL
|
|
// @Description Generate a temporary presigned URL for file preview (24h expiry)
|
|
// @Tags Files
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param bucket_name query string true "Bucket name"
|
|
// @Param object_key query string true "Object key"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/preview [get]
|
|
func (e *FileEndpoint) GetPreviewURL(c *gin.Context) {
|
|
var req requests.GetFilePreviewRequest
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := e.FileValidator.ValidatePreview(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
query := handlers.GetFilePreviewQuery{
|
|
BucketName: req.BucketName,
|
|
ObjectKey: req.ObjectKey,
|
|
Expiry: 24 * time.Hour,
|
|
}
|
|
|
|
result, err := mediator.Send[handlers.GetFilePreviewQuery, string](e.Mediator, c.Request.Context(), query)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"url": result})
|
|
}
|
|
|
|
// GetFileContent godoc
|
|
// @Summary Get file text content
|
|
// @Description Retrieve text content of a file for preview (e.g., Markdown)
|
|
// @Tags Files
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param bucket_name query string true "Bucket name"
|
|
// @Param object_key query string true "Object key"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/content [get]
|
|
func (e *FileEndpoint) GetFileContent(c *gin.Context) {
|
|
var req requests.GetFileContentRequest
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := e.FileValidator.ValidateGetContent(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
query := handlers.GetFileContentQuery{
|
|
BucketName: req.BucketName,
|
|
ObjectKey: req.ObjectKey,
|
|
}
|
|
|
|
result, err := mediator.Send[handlers.GetFileContentQuery, string](e.Mediator, c.Request.Context(), query)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"content": result})
|
|
}
|
|
|
|
// InitMultipart godoc
|
|
// @Summary Initialize multipart upload
|
|
// @Description Start a new multipart upload session and return upload_id
|
|
// @Tags Multipart Upload
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param request body requests.InitMultipartRequest true "Request parameters"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/multipart/init [post]
|
|
func (e *FileEndpoint) InitMultipart(c *gin.Context) {
|
|
var req requests.InitMultipartRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := e.FileValidator.ValidateInitMultipart(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
cmd := handlers.InitMultipartCommand{BucketName: req.BucketName, ObjectKey: req.ObjectKey}
|
|
result, err := mediator.Send[handlers.InitMultipartCommand, string](e.Mediator, c.Request.Context(), cmd)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"upload_id": result})
|
|
}
|
|
|
|
// UploadPart godoc
|
|
// @Summary Upload a part
|
|
// @Description Upload a single part of a multipart upload (5MB recommended per part)
|
|
// @Tags Multipart Upload
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param bucket_name formData string true "Bucket name"
|
|
// @Param object_key formData string true "Object key"
|
|
// @Param upload_id formData string true "Upload ID"
|
|
// @Param part_number formData int true "Part number (starting from 1)"
|
|
// @Param file formData file true "Part data"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/multipart/part [put]
|
|
func (e *FileEndpoint) UploadPart(c *gin.Context) {
|
|
var req requests.UploadPartRequest
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := e.FileValidator.ValidateUploadPart(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
file, err := req.File.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open part file"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
cmd := handlers.UploadPartCommand{
|
|
BucketName: req.BucketName,
|
|
ObjectKey: req.ObjectKey,
|
|
UploadId: req.UploadId,
|
|
PartNumber: req.PartNumber,
|
|
Data: file,
|
|
}
|
|
result, err := mediator.Send[handlers.UploadPartCommand, string](e.Mediator, c.Request.Context(), cmd)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"etag": result})
|
|
}
|
|
|
|
// CompleteMultipart godoc
|
|
// @Summary Complete multipart upload
|
|
// @Description Assemble all parts to complete the upload
|
|
// @Tags Multipart Upload
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param request body requests.CompleteMultipartRequest true "Request parameters"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/multipart/complete [post]
|
|
func (e *FileEndpoint) CompleteMultipart(c *gin.Context) {
|
|
var req requests.CompleteMultipartRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := e.FileValidator.ValidateCompleteMultipart(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
cmd := handlers.CompleteMultipartCommand{
|
|
BucketName: req.BucketName,
|
|
ObjectKey: req.ObjectKey,
|
|
UploadId: req.UploadId,
|
|
Parts: req.Parts,
|
|
}
|
|
result, err := mediator.Send[handlers.CompleteMultipartCommand, string](e.Mediator, c.Request.Context(), cmd)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"location": result})
|
|
}
|
|
|
|
// AbortMultipart godoc
|
|
// @Summary Abort multipart upload
|
|
// @Description Cancel an in-progress multipart upload
|
|
// @Tags Multipart Upload
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param request body requests.AbortMultipartRequest true "Request parameters"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/multipart/abort [post]
|
|
func (e *FileEndpoint) AbortMultipart(c *gin.Context) {
|
|
var req requests.AbortMultipartRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := e.FileValidator.ValidateAbortMultipart(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
cmd := handlers.AbortMultipartCommand{
|
|
BucketName: req.BucketName,
|
|
ObjectKey: req.ObjectKey,
|
|
UploadId: req.UploadId,
|
|
}
|
|
result, err := mediator.Send[handlers.AbortMultipartCommand, string](e.Mediator, c.Request.Context(), cmd)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": result})
|
|
}
|
|
|
|
// DeleteFile godoc
|
|
// @Summary Delete file
|
|
// @Description Delete a file from the specified bucket (irreversible)
|
|
// @Tags Files
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param request body requests.DeleteFileRequest true "Request parameters"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /files/delete [delete]
|
|
func (e *FileEndpoint) DeleteFile(c *gin.Context) {
|
|
var req requests.DeleteFileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := e.FileValidator.ValidateDeleteFile(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
cmd := handlers.DeleteFileCommand{
|
|
BucketName: req.BucketName,
|
|
ObjectKey: req.ObjectKey,
|
|
}
|
|
|
|
result, err := mediator.Send[handlers.DeleteFileCommand, string](e.Mediator, c.Request.Context(), cmd)
|
|
if err != nil {
|
|
handleError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": result})
|
|
}
|