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 }