Files
ReSendit/internal/file/handlers.go
2026-05-06 00:32:21 +02:00

495 lines
11 KiB
Go

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,
})
}