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