Spaces:
Running
Running
| package controllers | |
| import ( | |
| "compress/gzip" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "net/http" | |
| "path/filepath" | |
| "strings" | |
| "github.com/gin-gonic/gin" | |
| "github.com/google/uuid" | |
| "abdanhafidz.com/go-boilerplate/models/dto" | |
| http_error "abdanhafidz.com/go-boilerplate/models/error" | |
| "abdanhafidz.com/go-boilerplate/services" | |
| ) | |
| type UploadController interface { | |
| Upload(ctx *gin.Context) | |
| GetFileByID(ctx *gin.Context) | |
| } | |
| type uploadController struct{ uploadService services.UploadService } | |
| func NewUploadController(uploadService services.UploadService) UploadController { | |
| return &uploadController{uploadService: uploadService} | |
| } | |
| // Upload godoc | |
| // @Summary Upload Files | |
| // @Description Upload one or more files to the server | |
| // @Tags Upload | |
| // @Accept multipart/form-data | |
| // @Produce json | |
| // @Param context formData string false "Upload Context (e.g., image, submission, material)" | |
| // @Param files formData file true "Files to upload (multiple allowed)" | |
| // @Success 201 {object} dto.FileUploadResponse | |
| // @Failure 400 {object} dto.ErrorResponse | |
| // @Failure 401 {object} dto.ErrorResponse | |
| // @Failure 422 {object} dto.ErrorResponse | |
| // @Failure 500 {object} dto.ErrorResponse | |
| // @Router /api/v1/files [post] | |
| func (c *uploadController) Upload(ctx *gin.Context) { | |
| if !strings.Contains(ctx.GetHeader("Content-Type"), "multipart/form-data") { | |
| ctx.JSON(http.StatusBadRequest, gin.H{ | |
| "status": "error", | |
| "code": "INVALID_FORM", | |
| "message": "Content-Type must be multipart/form-data", | |
| }) | |
| return | |
| } | |
| if strings.EqualFold(ctx.GetHeader("Content-Encoding"), "gzip") { | |
| gz, err := gzip.NewReader(ctx.Request.Body) | |
| if err != nil { | |
| ctx.JSON(http.StatusBadRequest, gin.H{ | |
| "status": "error", | |
| "code": "INVALID_FORM", | |
| "message": "Failed to decode gzip request body", | |
| }) | |
| return | |
| } | |
| ctx.Request.Body = io.NopCloser(gz) | |
| } | |
| // Gunakan limit 32MB | |
| if err := ctx.Request.ParseMultipartForm(32 << 20); err != nil { | |
| fmt.Println("❌ ERROR ParseMultipartForm:", err.Error()) | |
| ctx.JSON(http.StatusBadRequest, gin.H{ | |
| "status": "error", | |
| "code": "INVALID_FORM", | |
| "message": "Failed to parse form data", | |
| "debug_error": err.Error(), | |
| }) | |
| return | |
| } | |
| form, err := ctx.MultipartForm() | |
| if err != nil { | |
| ctx.JSON(http.StatusBadRequest, gin.H{ | |
| "status": "error", | |
| "code": "INVALID_DATA", | |
| "message": "Invalid form data", | |
| }) | |
| return | |
| } | |
| files := form.File["files"] | |
| if len(files) == 0 { | |
| ctx.JSON(http.StatusBadRequest, gin.H{ | |
| "status": "error", | |
| "message": "No files uploaded", | |
| }) | |
| return | |
| } | |
| uploadContext := ctx.PostForm("context") | |
| if uploadContext == "" { | |
| ext := strings.ToLower(filepath.Ext(files[0].Filename)) | |
| uploadContext = c.inferContextFromExt(ext) | |
| } | |
| accountIDStr := ctx.GetString("account_id") | |
| if accountIDStr == "" { | |
| ctx.JSON(http.StatusUnauthorized, gin.H{ | |
| "status": "error", | |
| "message": "Unauthorized: Missing account ID", | |
| }) | |
| return | |
| } | |
| accountID, err := uuid.Parse(accountIDStr) | |
| if err != nil { | |
| ctx.JSON(http.StatusUnauthorized, gin.H{ | |
| "status": "error", | |
| "message": "Unauthorized: Invalid UUID format", | |
| }) | |
| return | |
| } | |
| uploadedFiles, err := c.uploadService.UploadFiles(ctx, files, uploadContext, accountID) | |
| if err != nil { | |
| if strings.Contains(err.Error(), "Invalid Compact JWS") { | |
| ctx.JSON(http.StatusInternalServerError, gin.H{ | |
| "status": "error", | |
| "message": "Storage misconfiguration: invalid Supabase service key", | |
| }) | |
| return | |
| } | |
| if errors.Is(err, http_error.FILE_TOO_LARGE) || | |
| errors.Is(err, http_error.INVALID_FILE_TYPE) || | |
| errors.Is(err, http_error.BAD_REQUEST_ERROR) || | |
| errors.Is(err, http_error.INVALID_UPLOAD_CONTEXT_ERROR) || | |
| errors.Is(err, http_error.INVALID_DATA_PAYLOAD) { | |
| ctx.JSON(http.StatusBadRequest, gin.H{ | |
| "status": "error", | |
| "message": err.Error(), | |
| }) | |
| return | |
| } | |
| if errors.Is(err, http_error.PARTIAL_UPLOAD_FAILURE) { | |
| ctx.JSON(http.StatusUnprocessableEntity, gin.H{ | |
| "status": "error", | |
| "message": err.Error(), | |
| "data": uploadedFiles, | |
| }) | |
| return | |
| } | |
| ctx.JSON(http.StatusInternalServerError, gin.H{ | |
| "status": "error", | |
| "message": err.Error(), | |
| }) | |
| return | |
| } | |
| var fileResponses []dto.FileResponse | |
| for _, f := range uploadedFiles { | |
| fileResponses = append(fileResponses, dto.FileResponse{ | |
| Id: f.Id, | |
| OriginalName: f.OriginalName, | |
| URL: f.Path, | |
| MimeType: f.MimeType, | |
| Size: f.Size, | |
| CreatedAt: f.CreatedAt, | |
| }) | |
| } | |
| ctx.JSON(http.StatusCreated, dto.FileUploadResponse{ | |
| Status: "success", | |
| Message: "Files uploaded successfully", | |
| Data: fileResponses, | |
| }) | |
| } | |
| // Get File By ID godoc | |
| // @Summary Get File by ID | |
| // @Description Retrieve file details using its ID | |
| // @Tags Upload | |
| // @Accept json | |
| // @Produce json | |
| // @Param id path string true "File ID" | |
| // @Success 200 {object} dto.FileResponseSingle | |
| // @Failure 400 {object} dto.ErrorResponse | |
| // @Failure 401 {object} dto.ErrorResponse | |
| // @Failure 404 {object} dto.ErrorResponse | |
| // @Router /api/v1/files/{id} [get] | |
| func (c *uploadController) GetFileByID(ctx *gin.Context) { | |
| fileIDStr := ctx.Param("id") | |
| fileID, err := uuid.Parse(fileIDStr) | |
| if err != nil { | |
| ctx.JSON(http.StatusBadRequest, gin.H{ | |
| "status": "error", | |
| "message": "Invalid file ID format", | |
| }) | |
| return | |
| } | |
| accountIDStr := ctx.GetString("account_id") | |
| if accountIDStr == "" { | |
| ctx.JSON(http.StatusUnauthorized, gin.H{ | |
| "status": "error", | |
| "message": "Unauthorized: Missing account ID", | |
| }) | |
| return | |
| } | |
| accountID, err := uuid.Parse(accountIDStr) | |
| if err != nil { | |
| ctx.JSON(http.StatusUnauthorized, gin.H{ | |
| "status": "error", | |
| "message": "Unauthorized: Invalid UUID format", | |
| }) | |
| return | |
| } | |
| fileData, err := c.uploadService.GetFileByID(ctx, fileID, accountID) | |
| if err != nil { | |
| if errors.Is(err, http_error.NOT_FOUND_ERROR) { | |
| ctx.JSON(http.StatusNotFound, gin.H{ | |
| "status": "error", | |
| "message": "File not found or access denied", | |
| }) | |
| return | |
| } | |
| ctx.JSON(http.StatusInternalServerError, gin.H{ | |
| "status": "error", | |
| "message": err.Error(), | |
| }) | |
| return | |
| } | |
| response := dto.FileResponse{ | |
| Id: fileData.Id, | |
| OriginalName: fileData.OriginalName, | |
| URL: fileData.Path, | |
| MimeType: fileData.MimeType, | |
| Size: fileData.Size, | |
| CreatedAt: fileData.CreatedAt, | |
| } | |
| ctx.JSON(http.StatusOK, dto.FileResponseSingle{ | |
| Status: "success", | |
| Message: "File retrieved successfully", | |
| Data: response, | |
| }) | |
| } | |
| // inferContextFromExt infers the upload context based on file extension | |
| func (c *uploadController) inferContextFromExt(ext string) string { | |
| images := map[string]bool{ | |
| ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, | |
| } | |
| isSourceCode := map[string]bool{ | |
| ".cpp": true, ".c": true, ".py": true, ".java": true, | |
| ".go": true, ".js": true, ".txt": true, | |
| } | |
| isDocument := map[string]bool{ | |
| ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".csv": true, | |
| } | |
| if images[ext] { | |
| return "image" | |
| } | |
| if isSourceCode[ext] { | |
| return "submission" | |
| } | |
| if isDocument[ext] { | |
| return "material" | |
| } | |
| return "" | |
| } | |