github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/vfs/archive.go (about) 1 package vfs 2 3 import ( 4 "archive/zip" 5 "fmt" 6 "io" 7 "net/http" 8 "net/url" 9 "path/filepath" 10 "strings" 11 12 "github.com/cozy/cozy-stack/pkg/consts" 13 "github.com/cozy/cozy-stack/pkg/couchdb" 14 "github.com/labstack/echo/v4" 15 ) 16 17 // ZipMime is the content-type for zip archives 18 const ZipMime = "application/zip" 19 20 // Archive is the data to create a zip archive 21 type Archive struct { 22 Name string `json:"name"` 23 Secret string `json:"-"` 24 IDs []string `json:"ids"` 25 Files []string `json:"files"` 26 27 // archiveEntries cache 28 entries []ArchiveEntry 29 } 30 31 // ArchiveEntry is an utility struct to store a file or doc to be placed 32 // in the archive. 33 type ArchiveEntry struct { 34 root string 35 Dir *DirDoc 36 File *FileDoc 37 } 38 39 var plusEscaper = strings.NewReplacer("+", "%20") 40 41 // ContentDisposition creates an HTTP header value for Content-Disposition 42 func ContentDisposition(disposition, filename string) string { 43 // RFC2616 §2.2 - syntax of quoted strings 44 escaped := strings.Map(func(r rune) rune { 45 if r == 34 || r == 47 || r == 92 { // double quote, slash, and anti-slash 46 return -1 47 } 48 if r > 32 && r < 127 { 49 return r 50 } 51 return -1 52 }, filename) 53 if escaped == "" { 54 escaped = "download" 55 } 56 if filename == escaped { 57 return fmt.Sprintf(`%s; filename="%s"`, disposition, escaped) 58 } 59 // RFC5987 §3.2 - syntax of ext value 60 encoded := url.QueryEscape(filename) 61 encoded = plusEscaper.Replace(encoded) 62 // RFC5987 §3.2.1 - syntax of regular and extended header value encoding 63 return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, escaped, encoded) 64 } 65 66 // GetEntries returns all files and folders in the archive as ArchiveEntry. 67 func (a *Archive) GetEntries(fs VFS) ([]ArchiveEntry, error) { 68 if a.entries == nil { 69 n := len(a.IDs) 70 entries := make([]ArchiveEntry, n+len(a.Files)) 71 for i, id := range a.IDs { 72 d, f, err := fs.DirOrFileByID(id) 73 if err != nil { 74 return nil, err 75 } 76 var root string 77 if d != nil { 78 root = d.Fullpath 79 } else { 80 root, err = f.Path(fs) 81 if err != nil { 82 return nil, err 83 } 84 } 85 entries[i] = ArchiveEntry{ 86 root: root, 87 Dir: d, 88 File: f, 89 } 90 } 91 for i, root := range a.Files { 92 d, f, err := fs.DirOrFileByPath(root) 93 if err != nil { 94 return nil, err 95 } 96 entries[n+i] = ArchiveEntry{ 97 root: root, 98 Dir: d, 99 File: f, 100 } 101 } 102 103 a.entries = entries 104 } 105 106 return a.entries, nil 107 } 108 109 // Serve creates on the fly the zip archive and streams in a http response 110 func (a *Archive) Serve(fs VFS, w http.ResponseWriter) error { 111 header := w.Header() 112 header.Set(echo.HeaderContentType, ZipMime) 113 header.Set(echo.HeaderContentDisposition, 114 ContentDisposition("attachment", a.Name+".zip")) 115 116 zw := zip.NewWriter(w) 117 defer zw.Close() 118 119 entries, err := a.GetEntries(fs) 120 if err != nil { 121 return err 122 } 123 124 for _, entry := range entries { 125 base := filepath.Dir(entry.root) 126 err = walk(fs, entry.root, entry.Dir, entry.File, func(name string, dir *DirDoc, file *FileDoc, err error) error { 127 if err != nil { 128 return err 129 } 130 name, err = filepath.Rel(base, name) 131 if err != nil { 132 return fmt.Errorf("Invalid filepath <%s>: %s", name, err) 133 } 134 if dir != nil { 135 _, err = zw.Create(a.Name + "/" + name + "/") 136 return err 137 } 138 header := &zip.FileHeader{ 139 Name: a.Name + "/" + name, 140 Method: zip.Deflate, 141 Modified: file.UpdatedAt, 142 } 143 ze, err := zw.CreateHeader(header) 144 if err != nil { 145 return fmt.Errorf("Can't create zip entry <%s>: %s", name, err) 146 } 147 f, err := fs.OpenFile(file) 148 if err != nil { 149 return fmt.Errorf("Can't open file <%s>: %s", name, err) 150 } 151 defer f.Close() 152 _, err = io.Copy(ze, f) 153 return err 154 }, 0) 155 if err != nil { 156 return err 157 } 158 } 159 160 return nil 161 } 162 163 // ID makes Archive a jsonapi.Object 164 func (a *Archive) ID() string { return a.Secret } 165 166 // Rev makes Archive a jsonapi.Object 167 func (a *Archive) Rev() string { return "" } 168 169 // DocType makes Archive a jsonapi.Object 170 func (a *Archive) DocType() string { return consts.Archives } 171 172 // Clone implements couchdb.Doc 173 func (a *Archive) Clone() couchdb.Doc { 174 cloned := *a 175 176 cloned.IDs = make([]string, len(a.IDs)) 177 copy(cloned.IDs, a.IDs) 178 179 cloned.Files = make([]string, len(a.Files)) 180 copy(cloned.Files, a.Files) 181 182 cloned.entries = make([]ArchiveEntry, len(a.entries)) 183 copy(cloned.entries, a.entries) 184 return &cloned 185 } 186 187 // SetID makes Archive a jsonapi.Object 188 func (a *Archive) SetID(_ string) {} 189 190 // SetRev makes Archive a jsonapi.Object 191 func (a *Archive) SetRev(_ string) {}