diff --git a/internal/file/handlers.go b/internal/file/handlers.go index 3bba5b9..e0c17d7 100644 --- a/internal/file/handlers.go +++ b/internal/file/handlers.go @@ -1,6 +1,7 @@ package file import ( + "ResendIt/internal/util" "fmt" "net/http" "path/filepath" @@ -77,27 +78,12 @@ func (h *Handler) View(c *gin.Context) { c.HTML(http.StatusOK, "fileNotFound.html", nil) return } - - c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, record.Filename)) + 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 safeFilename(name string) string { - // keep it simple: drop control chars and quotes - out := make([]rune, 0, len(name)) - for _, r := range name { - if r < 32 || r == 127 || r == '"' || r == '\\' { - continue - } - out = append(out, r) - } - if len(out) == 0 { - return "file" - } - return string(out) -} - func isXSSRisk(filename string) bool { ext := filepath.Ext(filename) switch ext { @@ -116,8 +102,8 @@ func (h *Handler) Download(c *gin.Context) { c.HTML(http.StatusOK, "fileNotFound.html", nil) return } - - c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, record.Filename)) + 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") diff --git a/internal/file/routes.go b/internal/file/routes.go index 8c2234e..690ae7c 100644 --- a/internal/file/routes.go +++ b/internal/file/routes.go @@ -10,6 +10,7 @@ func RegisterRoutes(r *gin.RouterGroup, h *Handler) { files := r.Group("/files") files.POST("/upload", h.Upload) + files.POST("/upload-multi", h.UploadMulti) //files.GET("/download/:id", h.Download) files.GET("/view/:id", h.View) diff --git a/internal/file/upload_multi.go b/internal/file/upload_multi.go new file mode 100644 index 0000000..4729705 --- /dev/null +++ b/internal/file/upload_multi.go @@ -0,0 +1,57 @@ +package file + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +// UploadMulti accepts up to 10 files, zips them server-side, and returns a single download/view key. +func (h *Handler) UploadMulti(c *gin.Context) { + if err := c.Request.ParseMultipartForm(0); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + form, err := c.MultipartForm() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid multipart form"}) + return + } + + files := form.File["files"] + if len(files) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing files"}) + return + } + if len(files) > 50 { + c.JSON(http.StatusBadRequest, gin.H{"error": "too many files (max 50)"}) + return + } + + once := c.PostForm("once") == "true" + + durationStr := c.PostForm("duration") + hours, err := strconv.Atoi(durationStr) + if err != nil || hours <= 0 { + hours = 24 + } + duration := time.Duration(hours) * time.Hour + + record, err := h.service.UploadBundle(files, once, duration) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + 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, + }) +} diff --git a/internal/file/zip.go b/internal/file/zip.go new file mode 100644 index 0000000..5a9bcb2 --- /dev/null +++ b/internal/file/zip.go @@ -0,0 +1,159 @@ +package file + +import ( + "ResendIt/internal/util" + "archive/zip" + "errors" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +func safeZipName(name string) string { + name = filepath.Base(name) + name = strings.ReplaceAll(name, "\\", "_") + name = strings.ReplaceAll(name, "/", "_") + name = strings.TrimSpace(name) + if name == "" || name == "." { + return "file" + } + return name +} + +func dedupeName(name string, seen map[string]int) string { + if _, ok := seen[name]; !ok { + seen[name] = 1 + return name + } + seen[name]++ + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + return fmt.Sprintf("%s (%d)%s", base, seen[name], ext) +} + +func cuteZipName(fileCount int) string { + adjective := util.RandomAdjective() + verb := util.RandomVerb() + thing := util.RandomThing() + return fmt.Sprintf("%d%s%s%s.zip", fileCount, adjective, verb, thing) +} + +// UploadBundle zips multiple uploaded files into a single .zip stored on disk and tracked as one FileRecord. +func (s *Service) UploadBundle(files []*multipart.FileHeader, deleteAfterDownload bool, expiresAfter time.Duration) (*FileRecord, error) { + if len(files) == 0 { + return nil, errors.New("no files") + } + if len(files) > 50 { + return nil, errors.New("too many files (max 50)") + } + + folderID := uuid.NewString() + folderPath := filepath.Join(s.storageDir, folderID) + if err := os.MkdirAll(folderPath, os.ModePerm); err != nil { + return nil, err + } + + zipDiskName := uuid.NewString() + ".zip" + zipPath := filepath.Join(folderPath, zipDiskName) + + out, err := os.Create(zipPath) + if err != nil { + return nil, err + } + defer func() { + _ = out.Close() + if err != nil { + _ = os.Remove(zipPath) + } + }() + + zw := zip.NewWriter(out) + defer func() { _ = zw.Close() }() + + seen := map[string]int{} + for _, fh := range files { + rc, openErr := fh.Open() + if openErr != nil { + err = openErr + return nil, openErr + } + + name := dedupeName(safeZipName(fh.Filename), seen) + h, _ := zip.FileInfoHeader(dummyFileInfo{name: name, size: fh.Size, mod: time.Now()}) + h.Name = name + h.Method = zip.Deflate + + w, createErr := zw.CreateHeader(h) + if createErr != nil { + _ = rc.Close() + err = createErr + return nil, createErr + } + + if _, copyErr := io.Copy(w, rc); copyErr != nil { + _ = rc.Close() + err = copyErr + return nil, copyErr + } + _ = rc.Close() + } + + if closeErr := zw.Close(); closeErr != nil { + err = closeErr + return nil, closeErr + } + if closeErr := out.Close(); closeErr != nil { + err = closeErr + return nil, closeErr + } + + zipDisplayName := cuteZipName(len(files)) + + f := &FileRecord{ + ID: folderID, + DeletionID: uuid.NewString(), + ViewID: uuid.NewString(), + Filename: zipDisplayName, + Path: zipPath, + Size: fileSize(zipPath), + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(expiresAfter), + DeleteAfterDownload: deleteAfterDownload, + } + + if err := s.repo.Create(f); err != nil { + return nil, err + } + + return f, nil +} + +func fileSize(path string) int64 { + st, err := os.Stat(path) + if err != nil { + return 0 + } + return st.Size() +} + +// dummyFileInfo provides minimal os.FileInfo for zip headers. +// This avoids relying on the underlying uploaded file having a real modtime. +// (zip.Writer can work without this too, but headers look nicer.) +type dummyFileInfo struct { + name string + size int64 + mod time.Time +} + +func (d dummyFileInfo) Name() string { return d.name } +func (d dummyFileInfo) Size() int64 { return d.size } +func (d dummyFileInfo) Mode() os.FileMode { return 0o644 } +func (d dummyFileInfo) ModTime() time.Time { return d.mod } +func (d dummyFileInfo) IsDir() bool { return false } +func (d dummyFileInfo) Sys() any { return nil } diff --git a/internal/util/util.go b/internal/util/util.go index 8acfc8d..d545b0d 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -1,6 +1,9 @@ package util -import "fmt" +import ( + "fmt" + "strings" +) func HumanSize(size int64) string { const unit = 1024 @@ -17,3 +20,24 @@ func HumanSize(size int64) string { "KMGTPE"[exp], ) } + +func SafeFilename(name string) string { + name = strings.TrimSpace(name) + + out := make([]rune, 0, len(name)) + for _, r := range name { + // block control chars (incl CR/LF/TAB), DEL, quotes, backslash + if r < 32 || r == 127 || r == '"' || r == '\\' { + continue + } + out = append(out, r) + } + if len(out) == 0 { + return "file" + } + // optional: cap length + if len(out) > 200 { + out = out[:200] + } + return string(out) +} diff --git a/internal/util/worldlist.go b/internal/util/worldlist.go new file mode 100644 index 0000000..e683ec4 --- /dev/null +++ b/internal/util/worldlist.go @@ -0,0 +1,47 @@ +package util + +import "math/rand" + +var Adjectives = []string{ + "Cool", "Super", "Hot", "Spicy", "Sneaky", "Sleepy", "Tiny", "Mega", "Cosmic", "Silly", + "Cursed", "Blessed", "Wiggly", "Giga", "Chonky", "Shiny", "Angry", "Happy", "Soft", "Turbo", + "Zany", "Snappy", "Fluffy", "Cranky", "Glitchy", "Bubbly", "Frosty", "Electric", "Jolly", "Mystic", + "Weird", "Chunky", "Psycho", "Cheesy", "Smelly", "Slippery", "Fiery", "Wacky", "Vivid", "Hyper", + "Soggy", "Grumpy", "Luminous", "Spooky", "Funky", "Twisted", "Nifty", "Prickly", "Velvet", "Epic", + "Glorious", "Majestic", "Quirky", "Radiant", "Sneaky", "Bouncy", "Mysterious", "Noodle", "Raging", "Zesty", + "Shimmering", "Fabled", "Plush", "Snazzy", "Stormy", "Gleaming", "Vibrant", "Odd", "Tasty", "Whimsical", + "Feral", "Clever", "Jumpy", "Dizzy", "Wicked", "Chilly", "Hasty", "Bizarre", "Snug", "Cheerful", +} + +var Things = []string{ + "Potato", "Griefers", "Raccoons", "Pigeons", "Wizards", "Ninjas", "Pickles", "Dragons", "Goblins", "Burgers", + "Pancakes", "Hamsters", "Bananas", "Comets", "Robots", "Cats", "Kiwis", "Frogs", "Cupcakes", "Sprites", + "Monsters", "Aliens", "Slimes", "Tacos", "Unicorns", "Ghosts", "Snails", "Vampires", "Donuts", "Owls", + "Zombies", "Mermaids", "Beavers", "Octopuses", "Chickens", "Penguins", "Mushrooms", "Felines", "Llamas", "Waffles", + "Baboons", "Dragettes", "Pixies", "Sharks", "Elephants", "Squirrels", "Gnomes", "Wombats", "Cacti", "Puppets", + "Koalas", "Moose", "Yeti", "Bats", "Crabs", "Otters", "Trolls", "Geckos", "Parrots", "Snakes", + "Sloths", "Clowns", "Jellyfish", "Froggies", "Dragoneers", "Nuggets", "Sprites", "Critters", "Knights", "Squids", + "Tigers", "Foxes", "Penguinos", "Burglebugs", "Clouds", "Fireflies", "Shrooms", "Mice", "Wizards", "Berries", +} + +var Verbs = []string{ + "Zoom", "Bonk", "Yeet", "Vibe", "Hack", "Spark", "Bounce", "Nibble", "Smuggle", "Cook", + "Flick", "Slap", "Whack", "Zap", "Blast", "Slam", "Twist", "Flip", "Slide", "Crash", + "Pop", "Fling", "Snatch", "Boing", "Sizzle", "Clap", "Roar", "Sniff", "Swoop", "Blink", + "Dodge", "Smash", "Roll", "Twirl", "Snore", "Drip", "Slurp", "Chomp", "Shuffle", "Juggle", + "Bounce", "Whirl", "Gush", "Spit", "Frolic", "Honk", "Wiggle", "Crackle", "Pounce", "Sprinkle", + "Slam", "Zoomerang", "Flop", "Squish", "Boop", "Whiz", "Flipflop", "Snip", "Glide", "Zapzap", + "Bop", "Wobble", "Fumble", "Twinkle", "Splash", "Dribble", "Clobber", "Whackadoo", "Bounceback", "Snizzle", +} + +func RandomAdjective() string { + return Adjectives[rand.Intn(len(Adjectives))] +} + +func RandomThing() string { + return Things[rand.Intn(len(Things))] +} + +func RandomVerb() string { + return Verbs[rand.Intn(len(Verbs))] +} diff --git a/templates/index.html b/templates/index.html index 8386fb9..93225b9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -102,7 +102,7 @@