package file import ( "ResendIt/internal/api/middleware" "ResendIt/internal/config" "ResendIt/internal/logger" "ResendIt/internal/notify" "ResendIt/internal/util" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "time" "github.com/gin-gonic/gin" ) type Handler struct { service *Service configService ConfigService } // ConfigService is the small interface we need from the config package. // Keeping it as an interface avoids import cycles and keeps file.Handler easy to test. type ConfigService interface { GetIntDefault(key string, def int) int GetInt64Default(key string, def int64) int64 GetStringDefault(key string, def string) string } func NewHandler(s *Service, cfg ConfigService) *Handler { return &Handler{service: s, configService: cfg} } func (h *Handler) Upload(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "file_upload"). Logger() err := c.Request.ParseMultipartForm(0) if err != nil { log.Warn().Str("reason", "parse_error").Err(err).Msg("Upload failed") c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } file, err := c.FormFile("file") if err != nil { log.Warn().Str("reason", "missing_file").Msg("Upload failed") c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) return } maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) if file.Size > maxSize { log.Warn(). Str("reason", "file_too_large"). Int64("file_size", file.Size). Int64("max_size", maxSize). Msg("Upload rejected") c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"}) return } f, err := file.Open() if err != nil { log.Error().Err(err).Msg("Failed to open uploaded file") c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) return } defer f.Close() once := c.PostForm("once") == "true" durationStr := c.PostForm("duration") hours, err := strconv.Atoi(durationStr) if err != nil || hours <= 0 { hours = 24 // default } maxHours := h.configService.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours) if hours > maxHours { hours = maxHours } duration := time.Duration(hours) * time.Hour record, err := h.service.UploadFile( file.Filename, f, once, duration, ) if err != nil { log.Error().Err(err).Msg("Upload failed") c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } log.Info(). Str("file_id", record.ID). Str("filename", record.Filename). Int64("file_size", record.Size). Bool("once", once). Msg("File uploaded successfully") enabled := h.configService.GetIntDefault(config.KeyUseNtfy, config.DefaultUseNtfy) if enabled == 1 { ntfyURL := h.configService.GetStringDefault(config.KeyNtfyUrl, "") topic := h.configService.GetStringDefault(config.KeyNtfyTopic, config.DefaultNtfyTopic) go func() { title := "ReSendit: new upload" msg := fmt.Sprintf("%s (%s)\nID: %s", record.Filename, util.HumanSize(record.Size), record.ID) clickUrl := fmt.Sprintf("f/%s", record.ViewID) if err := notify.Publish(ntfyURL, topic, title, msg, clickUrl); err != nil { logger.Log.Warn().Err(err).Str("type", "ntfy").Msg("ntfy publish failed") } }() } c.JSON(http.StatusOK, gin.H{ "id": record.ID, "deletion_id": record.DeletionID, "filename": record.Filename, "size": record.Size, "expires_at": record.ExpiresAt, "view_key": record.ViewID, }) } func (h *Handler) View(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "file_view"). Logger() id := c.Param("id") record, err := h.service.DownloadFile(id) if err != nil { log.Warn().Str("file_id", id).Err(err).Msg("File view failed - not found") c.HTML(http.StatusOK, "error.html", nil) return } log.Info().Str("file_id", id).Str("filename", record.Filename).Msg("File viewed") name := util.SafeFilename(record.Filename) c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) c.Header("X-Content-Type-Options", "nosniff") c.File(record.Path) } func isXSSRisk(filename string) bool { ext := filepath.Ext(filename) switch ext { case ".html", ".htm", ".js", ".css", ".svg": return true default: return false } } func (h *Handler) Download(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "file_download"). Logger() id := c.Param("id") record, err := h.service.DownloadFile(id) if err != nil { log.Warn().Str("file_id", id).Err(err).Msg("File download failed - not found") c.HTML(http.StatusOK, "error.html", nil) return } log.Info().Str("file_id", id).Str("filename", record.Filename).Int64("size", record.Size).Msg("File downloaded") name := util.SafeFilename(record.Filename) c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) c.Header("X-Content-Type-Options", "nosniff") c.File(record.Path) } func (h *Handler) Delete(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "file_delete"). Logger() id := c.Param("del_id") record, err := h.service.DeleteFileByDeletionID(id) if err != nil { log.Warn().Str("deletion_id", id).Err(err).Msg("File delete failed") c.HTML(http.StatusOK, "error.html", nil) return } log.Info(). Str("file_id", record.ID). Str("filename", record.Filename). Msg("File deleted") c.HTML(http.StatusOK, "deleted.html", nil) } func (h *Handler) AdminList(c *gin.Context) { records, err := h.service.repo.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, records) } func (h *Handler) AdminGet(c *gin.Context) { id := c.Param("id") record, err := h.service.repo.GetByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, util.SafeFilename(record.Filename))) c.Header("X-Content-Type-Options", "nosniff") c.File(record.Path) } func (h *Handler) AdminDelete(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "admin_file_delete"). Logger() id := c.Param("id") record, err := h.service.DeleteFileByID(id) if err != nil { log.Warn().Str("file_id", id).Err(err).Msg("Admin file delete failed") c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } log.Info().Str("file_id", record.ID).Str("filename", record.Filename).Msg("Admin deleted file") c.Redirect(301, "/admin") } func (h *Handler) AdminForceDelete(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "admin_file_force_delete"). Logger() id := c.Param("id") _, err := h.service.GetFileByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } if _, err := h.service.ForceDelete(id); err != nil { log.Error().Err(err).Msg("Admin force delete failed") c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } log.Info().Str("file_id", id).Msg("Admin force deleted file") c.Redirect(301, "/admin") } func (h *Handler) AdminReinstate(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "admin_file_reinstate"). Logger() id := c.Param("id") _, err := h.service.GetFileByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } if _, err := h.service.ReinstateFile(id); err != nil { log.Error().Err(err).Msg("Admin reinstate failed") c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } log.Info().Str("file_id", id).Msg("Admin reinstated file") c.Redirect(301, "/admin") } func (h *Handler) Import(c *gin.Context) { var records []ImportFileRecord if err := c.ShouldBindJSON(&records); err != nil { c.JSON(400, gin.H{"error": "invalid JSON"}) return } if err := h.service.ImportFiles(records); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{ "imported": len(records), }) } func (h *Handler) Export(c *gin.Context) { records, err := h.service.GetAllFiles() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, records) } // Chunked stuff func (h *Handler) UploadInit(c *gin.Context) { var req struct { Filename string `json:"filename"` TotalChunks int `json:"totalChunks"` Size int64 `json:"size"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "invalid request"}) return } fileID := util.RandomString(32) // create temp folder path := filepath.Join("tmp", fileID) if err := os.MkdirAll(path, os.ModePerm); err != nil { c.JSON(500, gin.H{"error": "failed to create temp dir"}) return } c.JSON(200, gin.H{ "fileId": fileID, }) } func (h *Handler) UploadChunk(c *gin.Context) { fileID := c.GetHeader("fileId") chunkIndex := c.GetHeader("chunkIndex") if fileID == "" || chunkIndex == "" { c.JSON(400, gin.H{"error": "missing headers"}) return } idx, err := strconv.Atoi(chunkIndex) if err != nil { c.JSON(400, gin.H{"error": "invalid chunkIndex"}) return } file, err := c.FormFile("chunk") if err != nil { c.JSON(400, gin.H{"error": "missing chunk"}) return } src, err := file.Open() if err != nil { c.JSON(500, gin.H{"error": "cannot open chunk"}) return } defer src.Close() chunkPath := filepath.Join("tmp", fileID, fmt.Sprintf("chunk_%d", idx)) dst, err := os.Create(chunkPath) if err != nil { c.JSON(500, gin.H{"error": "cannot save chunk"}) return } defer dst.Close() if _, err := io.Copy(dst, src); err != nil { c.JSON(500, gin.H{"error": "write failed"}) return } c.JSON(200, gin.H{"status": "ok"}) } func (h *Handler) UploadComplete(c *gin.Context) { log := middleware.StructuredLog(c).With(). Str("event", "chunked_upload_complete"). Logger() var req struct { FileID string `json:"fileId"` Filename string `json:"filename"` TotalChunks int `json:"totalChunks"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "invalid request"}) return } tmpDir := filepath.Join("tmp", req.FileID) // create pipe to stream into your existing service pr, pw := io.Pipe() go func() { defer pw.Close() for i := 0; i < req.TotalChunks; i++ { chunkPath := filepath.Join(tmpDir, fmt.Sprintf("chunk_%d", i)) f, err := os.Open(chunkPath) if err != nil { pw.CloseWithError(err) return } if _, err := io.Copy(pw, f); err != nil { f.Close() pw.CloseWithError(err) return } f.Close() } }() record, err := h.service.UploadFile( req.Filename, pr, false, 24*time.Hour, ) if err != nil { log.Error().Err(err).Msg("Chunked upload failed") c.JSON(500, gin.H{"error": err.Error()}) return } _ = os.RemoveAll(tmpDir) log.Info(). Str("file_id", record.ID). Str("filename", record.Filename). Int("chunks", req.TotalChunks). Msg("Chunked upload completed") c.JSON(200, gin.H{ "id": record.ID, "view_key": record.ViewID, }) } func (h *Handler) UploadStatus(c *gin.Context) { fileID := c.Param("fileId") dir := filepath.Join("tmp", fileID) files, err := os.ReadDir(dir) if err != nil { c.JSON(404, gin.H{"error": "not found"}) return } var uploaded []int for _, f := range files { var idx int _, err := fmt.Sscanf(f.Name(), "chunk_%d", &idx) if err == nil { uploaded = append(uploaded, idx) } } c.JSON(200, gin.H{ "uploadedChunks": uploaded, }) }