From 09d919ca27c4e230b5e154f1d06e1be1b4cb5afd Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Mar 2026 17:02:26 +0100 Subject: [PATCH 1/4] Add multi-file upload that zips files server-side --- internal/file/routes.go | 1 + internal/file/upload_multi.go | 57 +++++++++++++ internal/file/zip.go | 151 ++++++++++++++++++++++++++++++++++ templates/index.html | 75 +++++++++++++---- 4 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 internal/file/upload_multi.go create mode 100644 internal/file/zip.go 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..a240d0b --- /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) > 10 { + c.JSON(http.StatusBadRequest, gin.H{"error": "too many files (max 10)"}) + 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..df73ced --- /dev/null +++ b/internal/file/zip.go @@ -0,0 +1,151 @@ +package file + +import ( + "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) +} + +// 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) > 10 { + return nil, errors.New("too many files (max 10)") + } + + 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 := fmt.Sprintf("bundle-%d-files.zip", 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/templates/index.html b/templates/index.html index 8386fb9..93225b9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -102,7 +102,7 @@
- +
Click to select or drop file @@ -232,20 +232,36 @@ input.onchange = () => { if (input.files.length) { - showFile(input.files[0]); + showFiles(input.files); uploadBtn.disabled = false; } else { uploadBtn.disabled = true; } }; - function showFile(file) { + function showFiles(fileList) { + const files = Array.from(fileList || []); + if (files.length === 0) return; + + const total = files.reduce((acc, f) => acc + f.size, 0); + + if (files.length === 1) { + document.getElementById('dz-text').innerText = + `${files[0].name} (${formatBytes(files[0].size)})`; + return; + } + document.getElementById('dz-text').innerText = - `${file.name} (${formatBytes(file.size)})`; + `${files.length} FILES (${formatBytes(total)}) — will be zipped`; } uploadBtn.onclick = () => { - if (input.files.length) handleUpload(input.files[0]); + if (!input.files.length) return; + if (input.files.length === 1) { + handleUploadSingle(input.files[0]); + } else { + handleUploadMulti(input.files); + } }; cancelBtn.onclick = (e) => { @@ -257,7 +273,15 @@ } }; - function handleUpload(file) { + function commonFormData() { + const fd = new FormData(); + fd.append("once", document.getElementById("once").checked ? "true" : "false"); + const hours = parseInt(document.getElementById("duration").value, 10); + fd.append("duration", hours); + return fd; + } + + function startUploadUI() { uploadBtn.disabled = true; uploadBtn.innerText = "UPLOADING..."; cancelBtn.classList.remove('hidden'); @@ -265,16 +289,9 @@ progressContainer.classList.remove("hidden"); progressText.classList.remove("hidden"); statsText.classList.remove("hidden"); + } - const fd = new FormData(); - fd.append("file", file); - fd.append("once", document.getElementById("once").checked ? "true" : "false"); - const hours = parseInt(document.getElementById("duration").value, 10); - fd.append("duration", hours); - - const xhr = new XMLHttpRequest(); - currentXhr = xhr; - + function setupXHRHandlers(xhr) { let startTime = Date.now(); xhr.upload.onprogress = (e) => { @@ -301,7 +318,6 @@ const data = JSON.parse(xhr.responseText); if (data.error) throw new Error(data.error); - // Redirect using view key window.location.href = "/f/" + data.view_key; } catch (err) { @@ -319,11 +335,38 @@ location.reload(); } }; + } + + function handleUploadSingle(file) { + startUploadUI(); + + const fd = commonFormData(); + fd.append("file", file); + + const xhr = new XMLHttpRequest(); + currentXhr = xhr; + + setupXHRHandlers(xhr); xhr.open("POST", "/api/files/upload"); xhr.send(fd); } + function handleUploadMulti(fileList) { + startUploadUI(); + + const fd = commonFormData(); + Array.from(fileList).forEach(f => fd.append("files", f)); + + const xhr = new XMLHttpRequest(); + currentXhr = xhr; + + setupXHRHandlers(xhr); + + xhr.open("POST", "/api/files/upload-multi"); + xhr.send(fd); + } + function copy(id) { const el = document.getElementById(id); el.select(); From db5c2558f8492a0c2ca701c729c2d5c7bb7489a0 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Mar 2026 17:10:15 +0100 Subject: [PATCH 2/4] Zip bundles: generate cutesy zip display names --- internal/file/zip.go | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/file/zip.go b/internal/file/zip.go index df73ced..545fde4 100644 --- a/internal/file/zip.go +++ b/internal/file/zip.go @@ -2,9 +2,11 @@ package file import ( "archive/zip" + "crypto/rand" "errors" "fmt" "io" + "math/big" "mime/multipart" "os" "path/filepath" @@ -36,6 +38,36 @@ func dedupeName(name string, seen map[string]int) string { return fmt.Sprintf("%s (%d)%s", base, seen[name], ext) } +func randIndex(n int) int { + if n <= 0 { + return 0 + } + x, err := rand.Int(rand.Reader, big.NewInt(int64(n))) + if err != nil { + return 0 + } + return int(x.Int64()) +} + +func cuteZipName(fileCount int) string { + adjectives := []string{ + "Cool", "Super", "Hot", "Spicy", "Sneaky", "Sleepy", "Tiny", "Mega", "Cosmic", "Silly", + "Cursed", "Blessed", "Wiggly", "Giga", "Chonky", "Shiny", "Angry", "Happy", "Soft", "Turbo", + } + things := []string{ + "Potato", "Griefers", "Raccoons", "Pigeons", "Wizards", "Ninjas", "Pickles", "Dragons", "Goblins", "Burgers", + "Pancakes", "Hamsters", "Bananas", "Comets", "Robots", "Cats", "Kiwis", "Frogs", "Cupcakes", "Sprites", + } + verbs := []string{ + "Zoom", "Bonk", "Yeet", "Vibe", "Hack", "Spark", "Bounce", "Nibble", "Smuggle", "Cook", + } + + a := adjectives[randIndex(len(adjectives))] + v := verbs[randIndex(len(verbs))] + t := things[randIndex(len(things))] + return fmt.Sprintf("%d%s%s%s.zip", fileCount, a, v, t) +} + // 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 { @@ -105,7 +137,7 @@ func (s *Service) UploadBundle(files []*multipart.FileHeader, deleteAfterDownloa return nil, closeErr } - zipDisplayName := fmt.Sprintf("bundle-%d-files.zip", len(files)) + zipDisplayName := cuteZipName(len(files)) f := &FileRecord{ ID: folderID, From 5bcca61d595a7f5d44301895d367e5804ec1c766 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 23 Mar 2026 17:17:57 +0100 Subject: [PATCH 3/4] Move wordlist and increase file limit --- internal/file/zip.go | 25 ++++++-------------- internal/util/worldlist.go | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 18 deletions(-) create mode 100644 internal/util/worldlist.go diff --git a/internal/file/zip.go b/internal/file/zip.go index 545fde4..51b6e80 100644 --- a/internal/file/zip.go +++ b/internal/file/zip.go @@ -1,6 +1,7 @@ package file import ( + "ResendIt/internal/util" "archive/zip" "crypto/rand" "errors" @@ -50,22 +51,10 @@ func randIndex(n int) int { } func cuteZipName(fileCount int) string { - adjectives := []string{ - "Cool", "Super", "Hot", "Spicy", "Sneaky", "Sleepy", "Tiny", "Mega", "Cosmic", "Silly", - "Cursed", "Blessed", "Wiggly", "Giga", "Chonky", "Shiny", "Angry", "Happy", "Soft", "Turbo", - } - things := []string{ - "Potato", "Griefers", "Raccoons", "Pigeons", "Wizards", "Ninjas", "Pickles", "Dragons", "Goblins", "Burgers", - "Pancakes", "Hamsters", "Bananas", "Comets", "Robots", "Cats", "Kiwis", "Frogs", "Cupcakes", "Sprites", - } - verbs := []string{ - "Zoom", "Bonk", "Yeet", "Vibe", "Hack", "Spark", "Bounce", "Nibble", "Smuggle", "Cook", - } - - a := adjectives[randIndex(len(adjectives))] - v := verbs[randIndex(len(verbs))] - t := things[randIndex(len(things))] - return fmt.Sprintf("%d%s%s%s.zip", fileCount, a, v, t) + 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. @@ -73,8 +62,8 @@ func (s *Service) UploadBundle(files []*multipart.FileHeader, deleteAfterDownloa if len(files) == 0 { return nil, errors.New("no files") } - if len(files) > 10 { - return nil, errors.New("too many files (max 10)") + if len(files) > 50 { + return nil, errors.New("too many files (max 50)") } folderID := uuid.NewString() 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))] +} From 82eb9de5f1f6226a4ce4ed5425a72f6b8e3e85a8 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 23 Mar 2026 17:46:27 +0100 Subject: [PATCH 4/4] fix file naming sanitisation --- internal/file/handlers.go | 24 +++++------------------- internal/file/upload_multi.go | 4 ++-- internal/file/zip.go | 13 ------------- internal/util/util.go | 26 +++++++++++++++++++++++++- 4 files changed, 32 insertions(+), 35 deletions(-) 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/upload_multi.go b/internal/file/upload_multi.go index a240d0b..4729705 100644 --- a/internal/file/upload_multi.go +++ b/internal/file/upload_multi.go @@ -26,8 +26,8 @@ func (h *Handler) UploadMulti(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "missing files"}) return } - if len(files) > 10 { - c.JSON(http.StatusBadRequest, gin.H{"error": "too many files (max 10)"}) + if len(files) > 50 { + c.JSON(http.StatusBadRequest, gin.H{"error": "too many files (max 50)"}) return } diff --git a/internal/file/zip.go b/internal/file/zip.go index 51b6e80..5a9bcb2 100644 --- a/internal/file/zip.go +++ b/internal/file/zip.go @@ -3,11 +3,9 @@ package file import ( "ResendIt/internal/util" "archive/zip" - "crypto/rand" "errors" "fmt" "io" - "math/big" "mime/multipart" "os" "path/filepath" @@ -39,17 +37,6 @@ func dedupeName(name string, seen map[string]int) string { return fmt.Sprintf("%s (%d)%s", base, seen[name], ext) } -func randIndex(n int) int { - if n <= 0 { - return 0 - } - x, err := rand.Int(rand.Reader, big.NewInt(int64(n))) - if err != nil { - return 0 - } - return int(x.Int64()) -} - func cuteZipName(fileCount int) string { adjective := util.RandomAdjective() verb := util.RandomVerb() 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) +}