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) {}