- Add MarkNotDeleted method to repository - Add ReinstateFile method to service - Add AdminReinstate handler - Add /reinstate/:id route - Add Reinstate button in admin menu for deleted files
418 lines
9.1 KiB
Go
418 lines
9.1 KiB
Go
package file
|
|
|
|
import (
|
|
"ResendIt/internal/config"
|
|
"ResendIt/internal/notify"
|
|
"ResendIt/internal/util"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"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) {
|
|
err := c.Request.ParseMultipartForm(0)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
|
return
|
|
}
|
|
|
|
maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes)
|
|
if file.Size > maxSize {
|
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
|
|
return
|
|
}
|
|
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
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 {
|
|
log.Printf("ntfy publish failed: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
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) {
|
|
id := c.Param("id")
|
|
|
|
record, err := h.service.DownloadFile(id)
|
|
if err != nil {
|
|
c.HTML(http.StatusOK, "error.html", nil)
|
|
return
|
|
}
|
|
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) {
|
|
id := c.Param("id")
|
|
|
|
record, err := h.service.DownloadFile(id)
|
|
if err != nil {
|
|
c.HTML(http.StatusOK, "error.html", nil)
|
|
return
|
|
}
|
|
name := util.SafeFilename(record.Filename)
|
|
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name))
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
//c.Header("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; script-src 'none'; style-src 'none';")
|
|
//c.Header("Content-Type", "application/octet-stream")
|
|
c.File(record.Path)
|
|
}
|
|
|
|
func (h *Handler) Delete(c *gin.Context) {
|
|
id := c.Param("del_id")
|
|
|
|
_, err := h.service.DeleteFileByDeletionID(id)
|
|
if err != nil {
|
|
c.HTML(http.StatusOK, "error.html", nil)
|
|
return
|
|
}
|
|
|
|
//c.JSON(http.StatusOK, gin.H{"status": "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.File(record.Path)
|
|
}
|
|
|
|
func (h *Handler) AdminDelete(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
_, err := h.service.DeleteFileByID(id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
|
return
|
|
}
|
|
|
|
c.Redirect(301, "/admin")
|
|
}
|
|
|
|
func (h *Handler) AdminForceDelete(c *gin.Context) {
|
|
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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Redirect(301, "/admin")
|
|
}
|
|
|
|
func (h *Handler) AdminReinstate(c *gin.Context) {
|
|
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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
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) {
|
|
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()
|
|
}
|
|
}()
|
|
|
|
// reuse your existing upload logic 👇
|
|
record, err := h.service.UploadFile(
|
|
req.Filename,
|
|
pr,
|
|
false,
|
|
24*time.Hour,
|
|
)
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// cleanup temp
|
|
_ = os.RemoveAll(tmpDir)
|
|
|
|
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,
|
|
})
|
|
}
|