github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/pkg/appfs/server.go (about)

     1  package appfs
     2  
     3  import (
     4  	"archive/tar"
     5  	"bytes"
     6  	"compress/gzip"
     7  	"context"
     8  	"crypto/md5"
     9  	"encoding/hex"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"mime"
    14  	"net/http"
    15  	"os"
    16  	"path"
    17  	"strconv"
    18  	"strings"
    19  	"sync"
    20  	"time"
    21  
    22  	"github.com/andybalholm/brotli"
    23  	"github.com/cozy/cozy-stack/pkg/consts"
    24  	web_utils "github.com/cozy/cozy-stack/pkg/utils"
    25  	lru "github.com/hashicorp/golang-lru/v2"
    26  	"github.com/labstack/echo/v4"
    27  	"github.com/ncw/swift/v2"
    28  	"github.com/spf13/afero"
    29  )
    30  
    31  // FileServer interface defines a way to access and serve the application's
    32  // data files.
    33  type FileServer interface {
    34  	Open(slug, version, shasum, file string) (io.ReadCloser, error)
    35  	FilesList(slug, version, shasum string) ([]string, error)
    36  	ServeFileContent(w http.ResponseWriter, req *http.Request,
    37  		slug, version, shasum, file string) error
    38  	ServeCodeTarball(w http.ResponseWriter, req *http.Request,
    39  		slug, version, shasum string) error
    40  }
    41  
    42  type swiftServer struct {
    43  	c         *swift.Connection
    44  	container string
    45  	ctx       context.Context
    46  }
    47  
    48  type aferoServer struct {
    49  	mkPath func(slug, version, shasum, file string) string
    50  	fs     afero.Fs
    51  }
    52  
    53  type brotliReadCloser struct {
    54  	br *brotli.Reader
    55  	cl io.Closer
    56  }
    57  
    58  // brotli.Reader has no Close method. This little wrapper adds a method to
    59  // close the underlying reader.
    60  func newBrotliReadCloser(r io.ReadCloser) (io.ReadCloser, error) {
    61  	br := brotli.NewReader(r)
    62  	return brotliReadCloser{br: br, cl: r}, nil
    63  }
    64  
    65  func (r brotliReadCloser) Read(b []byte) (int, error) {
    66  	return r.br.Read(b)
    67  }
    68  
    69  func (r brotliReadCloser) Close() error {
    70  	return r.cl.Close()
    71  }
    72  
    73  type gzipReadCloser struct {
    74  	gr *gzip.Reader
    75  	cl io.Closer
    76  }
    77  
    78  // The Close method of gzip.Reader does not closes the underlying reader. This
    79  // little wrapper does the closing.
    80  func newGzipReadCloser(r io.ReadCloser) (io.ReadCloser, error) {
    81  	gr, err := gzip.NewReader(r)
    82  	if err != nil {
    83  		return nil, err
    84  	}
    85  	return gzipReadCloser{gr: gr, cl: r}, nil
    86  }
    87  
    88  func (g gzipReadCloser) Read(b []byte) (int, error) {
    89  	return g.gr.Read(b)
    90  }
    91  
    92  func (g gzipReadCloser) Close() error {
    93  	err1 := g.gr.Close()
    94  	err2 := g.cl.Close()
    95  	if err1 != nil {
    96  		return err1
    97  	}
    98  	if err2 != nil {
    99  		return err2
   100  	}
   101  	return nil
   102  }
   103  
   104  type cacheEntry struct {
   105  	content []byte
   106  	headers swift.Headers
   107  }
   108  
   109  var cache *lru.Cache[string, cacheEntry]
   110  var initCacheOnce sync.Once
   111  
   112  // NewSwiftFileServer returns provides the apps.FileServer implementation
   113  // using the swift backend as file server.
   114  func NewSwiftFileServer(conn *swift.Connection, appsType consts.AppType) FileServer {
   115  	initCacheOnce.Do(func() {
   116  		c, err := lru.New[string, cacheEntry](128)
   117  		if err != nil {
   118  			panic(err)
   119  		}
   120  		cache = c
   121  	})
   122  	return &swiftServer{
   123  		c:         conn,
   124  		container: containerName(appsType),
   125  		ctx:       context.Background(),
   126  	}
   127  }
   128  
   129  func (s *swiftServer) openWithCache(objName string) (io.ReadCloser, swift.Headers, error) {
   130  	entry, ok := cache.Get(objName)
   131  	if !ok {
   132  		f, h, err := s.c.ObjectOpen(s.ctx, s.container, objName, false, nil)
   133  		if err != nil {
   134  			return f, h, err
   135  		}
   136  		entry.headers = h
   137  		entry.content, err = io.ReadAll(f)
   138  		if err != nil {
   139  			return nil, h, err
   140  		}
   141  		cache.Add(objName, entry)
   142  	}
   143  	f := io.NopCloser(bytes.NewReader(entry.content))
   144  	return f, entry.headers, nil
   145  }
   146  
   147  func (s *swiftServer) Open(slug, version, shasum, file string) (io.ReadCloser, error) {
   148  	objName := s.makeObjectName(slug, version, shasum, file)
   149  	f, h, err := s.openWithCache(objName)
   150  	if err != nil {
   151  		return nil, wrapSwiftErr(err)
   152  	}
   153  	o := h.ObjectMetadata()
   154  	contentEncoding := o["content-encoding"]
   155  	if contentEncoding == "br" {
   156  		return newBrotliReadCloser(f)
   157  	} else if contentEncoding == "gzip" {
   158  		return newGzipReadCloser(f)
   159  	}
   160  	return f, nil
   161  }
   162  
   163  func (s *swiftServer) ServeFileContent(w http.ResponseWriter, req *http.Request, slug, version, shasum, file string) error {
   164  	objName := s.makeObjectName(slug, version, shasum, file)
   165  	f, h, err := s.openWithCache(objName)
   166  	if err != nil {
   167  		return wrapSwiftErr(err)
   168  	}
   169  	defer f.Close()
   170  
   171  	if checkETag := req.Header.Get("Cache-Control") == ""; checkETag {
   172  		etag := fmt.Sprintf(`"%s"`, h["Etag"][:10])
   173  		if web_utils.CheckPreconditions(w, req, etag) {
   174  			return nil
   175  		}
   176  		w.Header().Set("Etag", etag)
   177  	}
   178  
   179  	var r io.Reader = f
   180  	contentLength := h["Content-Length"]
   181  	contentType := h["Content-Type"]
   182  	o := h.ObjectMetadata()
   183  	contentEncoding := o["content-encoding"]
   184  	if contentEncoding == "br" {
   185  		if acceptBrotliEncoding(req) {
   186  			w.Header().Set(echo.HeaderContentEncoding, "br")
   187  		} else {
   188  			contentLength = o["original-content-length"]
   189  			r = brotli.NewReader(f)
   190  		}
   191  	} else if contentEncoding == "gzip" {
   192  		if acceptGzipEncoding(req) {
   193  			w.Header().Set(echo.HeaderContentEncoding, "gzip")
   194  		} else {
   195  			contentLength = o["original-content-length"]
   196  			var gr *gzip.Reader
   197  			gr, err = gzip.NewReader(f)
   198  			if err != nil {
   199  				return err
   200  			}
   201  			defer gr.Close()
   202  			r = gr
   203  		}
   204  	}
   205  
   206  	ext := path.Ext(file)
   207  	if contentType == "" {
   208  		contentType = mime.TypeByExtension(ext)
   209  	}
   210  	if contentType == "text/xml" && ext == ".svg" {
   211  		// override for files with text/xml content because of leading <?xml tag
   212  		contentType = "image/svg+xml"
   213  	}
   214  
   215  	size, _ := strconv.ParseInt(contentLength, 10, 64)
   216  
   217  	return serveContent(w, req, contentType, size, r)
   218  }
   219  
   220  func (s *swiftServer) ServeCodeTarball(w http.ResponseWriter, req *http.Request, slug, version, shasum string) error {
   221  	objName := path.Join(slug, version)
   222  	if shasum != "" {
   223  		objName += "-" + shasum
   224  	}
   225  	objName += ".tgz"
   226  
   227  	f, h, err := s.c.ObjectOpen(s.ctx, s.container, objName, false, nil)
   228  	if err == nil {
   229  		defer f.Close()
   230  		contentLength := h["Content-Length"]
   231  		contentType := h["Content-Type"]
   232  		size, _ := strconv.ParseInt(contentLength, 10, 64)
   233  
   234  		return serveContent(w, req, contentType, size, f)
   235  	}
   236  
   237  	buf, err := prepareTarball(s, slug, version, shasum)
   238  	if err != nil {
   239  		return err
   240  	}
   241  	contentType := mime.TypeByExtension(".gz")
   242  
   243  	file, err := s.c.ObjectCreate(s.ctx, s.container, objName, true, "", contentType, nil)
   244  	if err == nil {
   245  		_, _ = io.Copy(file, buf)
   246  		_ = file.Close()
   247  	}
   248  
   249  	return serveContent(w, req, contentType, int64(buf.Len()), buf)
   250  }
   251  
   252  func (s *swiftServer) makeObjectName(slug, version, shasum, file string) string {
   253  	basepath := path.Join(slug, version)
   254  	if shasum != "" {
   255  		basepath += "-" + shasum
   256  	}
   257  	return path.Join(basepath, file)
   258  }
   259  
   260  func (s *swiftServer) FilesList(slug, version, shasum string) ([]string, error) {
   261  	prefix := s.makeObjectName(slug, version, shasum, "") + "/"
   262  	names, err := s.c.ObjectNamesAll(s.ctx, s.container, &swift.ObjectsOpts{
   263  		Limit:  10_000,
   264  		Prefix: prefix,
   265  	})
   266  	if err != nil {
   267  		return nil, err
   268  	}
   269  	filtered := names[:0]
   270  	for _, n := range names {
   271  		n = strings.TrimPrefix(n, prefix)
   272  		if n != "" {
   273  			filtered = append(filtered, n)
   274  		}
   275  	}
   276  	return filtered, nil
   277  }
   278  
   279  // NewAferoFileServer returns a simple wrapper of the afero.Fs interface that
   280  // provides the apps.FileServer interface.
   281  //
   282  // You can provide a makePath method to define how the file name should be
   283  // created from the application's slug, version and file name. If not provided,
   284  // the standard VFS concatenation (starting with vfs.WebappsDirName) is used.
   285  func NewAferoFileServer(fs afero.Fs, makePath func(slug, version, shasum, file string) string) FileServer {
   286  	if makePath == nil {
   287  		makePath = defaultMakePath
   288  	}
   289  	return &aferoServer{
   290  		mkPath: makePath,
   291  		fs:     fs,
   292  	}
   293  }
   294  
   295  const (
   296  	uncompressed = iota + 1
   297  	gzipped
   298  	brotlied
   299  )
   300  
   301  // openFile opens the give filepath. By default, it is a file compressed with
   302  // brotli (.br), but it can be a file compressed with gzip (.gz, for apps that
   303  // were installed before brotli compression was enabled), or uncompressed (for
   304  // app development with cozy-stack serve --appdir).
   305  func (s *aferoServer) openFile(filepath string) (afero.File, int, error) {
   306  	compression := brotlied
   307  	f, err := s.fs.Open(filepath + ".br")
   308  	if os.IsNotExist(err) {
   309  		compression = gzipped
   310  		f, err = s.fs.Open(filepath + ".gz")
   311  	}
   312  	if os.IsNotExist(err) {
   313  		compression = uncompressed
   314  		f, err = s.fs.Open(filepath)
   315  	}
   316  	return f, compression, err
   317  }
   318  
   319  func (s *aferoServer) Open(slug, version, shasum, file string) (io.ReadCloser, error) {
   320  	filepath := s.mkPath(slug, version, shasum, file)
   321  	f, compression, err := s.openFile(filepath)
   322  	if err != nil {
   323  		return nil, err
   324  	}
   325  	switch compression {
   326  	case uncompressed:
   327  		return f, nil
   328  	case gzipped:
   329  		return newGzipReadCloser(f)
   330  	case brotlied:
   331  		return newBrotliReadCloser(f)
   332  	default:
   333  		panic(fmt.Errorf("Unknown compression type: %v", compression))
   334  	}
   335  }
   336  
   337  func (s *aferoServer) ServeFileContent(w http.ResponseWriter, req *http.Request, slug, version, shasum, file string) error {
   338  	filepath := s.mkPath(slug, version, shasum, file)
   339  	return s.serveFileContent(w, req, filepath)
   340  }
   341  
   342  func (s *aferoServer) serveFileContent(w http.ResponseWriter, req *http.Request, filepath string) error {
   343  	f, compression, err := s.openFile(filepath)
   344  	if err != nil {
   345  		return err
   346  	}
   347  	defer f.Close()
   348  
   349  	var content io.Reader
   350  	var size int64
   351  	if checkEtag := req.Header.Get("Cache-Control") == ""; checkEtag {
   352  		var b []byte
   353  		h := md5.New()
   354  		b, err = io.ReadAll(f)
   355  		if err != nil {
   356  			return err
   357  		}
   358  		etag := fmt.Sprintf(`"%s"`, hex.EncodeToString(h.Sum(nil)))
   359  		if web_utils.CheckPreconditions(w, req, etag) {
   360  			return nil
   361  		}
   362  		w.Header().Set("Etag", etag)
   363  		size = int64(len(b))
   364  		content = bytes.NewReader(b)
   365  	} else {
   366  		size, err = f.Seek(0, io.SeekEnd)
   367  		if err != nil {
   368  			return err
   369  		}
   370  		_, err = f.Seek(0, io.SeekStart)
   371  		if err != nil {
   372  			return err
   373  		}
   374  		content = f
   375  	}
   376  
   377  	switch compression {
   378  	case uncompressed:
   379  		// Nothing to do
   380  	case gzipped:
   381  		if acceptGzipEncoding(req) {
   382  			w.Header().Set(echo.HeaderContentEncoding, "gzip")
   383  		} else {
   384  			var gr *gzip.Reader
   385  			var b []byte
   386  			gr, err = gzip.NewReader(content)
   387  			if err != nil {
   388  				return err
   389  			}
   390  			defer gr.Close()
   391  			b, err = io.ReadAll(gr)
   392  			if err != nil {
   393  				return err
   394  			}
   395  			size = int64(len(b))
   396  			content = bytes.NewReader(b)
   397  		}
   398  	case brotlied:
   399  		if acceptBrotliEncoding(req) {
   400  			w.Header().Set(echo.HeaderContentEncoding, "br")
   401  		} else {
   402  			var b []byte
   403  			br := brotli.NewReader(content)
   404  			b, err = io.ReadAll(br)
   405  			if err != nil {
   406  				return err
   407  			}
   408  			size = int64(len(b))
   409  			content = bytes.NewReader(b)
   410  		}
   411  	default:
   412  		panic(fmt.Errorf("Unknown compression type: %v", compression))
   413  	}
   414  
   415  	contentType := mime.TypeByExtension(path.Ext(filepath))
   416  	return serveContent(w, req, contentType, size, content)
   417  }
   418  
   419  func (s *aferoServer) ServeCodeTarball(w http.ResponseWriter, req *http.Request, slug, version, shasum string) error {
   420  	buf, err := prepareTarball(s, slug, version, shasum)
   421  	if err != nil {
   422  		return err
   423  	}
   424  
   425  	contentType := mime.TypeByExtension(".gz")
   426  
   427  	return serveContent(w, req, contentType, int64(buf.Len()), buf)
   428  }
   429  
   430  func (s *aferoServer) FilesList(slug, version, shasum string) ([]string, error) {
   431  	var names []string
   432  	rootPath := s.mkPath(slug, version, shasum, "")
   433  	err := afero.Walk(s.fs, rootPath, func(path string, infos os.FileInfo, err error) error {
   434  		if err != nil {
   435  			return err
   436  		}
   437  		if !infos.IsDir() {
   438  			name := strings.TrimPrefix(path, rootPath)
   439  			name = strings.TrimSuffix(name, ".gz")
   440  			name = strings.TrimSuffix(name, ".br")
   441  			names = append(names, name)
   442  		}
   443  		return nil
   444  	})
   445  	return names, err
   446  }
   447  
   448  func defaultMakePath(slug, version, shasum, file string) string {
   449  	basepath := path.Join("/", slug, version)
   450  	if shasum != "" {
   451  		basepath += "-" + shasum
   452  	}
   453  	filepath := path.Join("/", file)
   454  	return path.Join(basepath, filepath)
   455  }
   456  
   457  func acceptBrotliEncoding(req *http.Request) bool {
   458  	return strings.Contains(req.Header.Get(echo.HeaderAcceptEncoding), "br")
   459  }
   460  
   461  func acceptGzipEncoding(req *http.Request) bool {
   462  	return strings.Contains(req.Header.Get(echo.HeaderAcceptEncoding), "gzip")
   463  }
   464  
   465  func containerName(appsType consts.AppType) string {
   466  	switch appsType {
   467  	case consts.WebappType:
   468  		return "apps-web"
   469  	case consts.KonnectorType:
   470  		return "apps-konnectors"
   471  	}
   472  	panic("Unknown AppType")
   473  }
   474  
   475  func wrapSwiftErr(err error) error {
   476  	if errors.Is(err, swift.ObjectNotFound) || errors.Is(err, swift.ContainerNotFound) {
   477  		return os.ErrNotExist
   478  	}
   479  	return err
   480  }
   481  
   482  func prepareTarball(s FileServer, slug, version, shasum string) (*bytes.Buffer, error) {
   483  	filenames, err := s.FilesList(slug, version, shasum)
   484  	if err != nil {
   485  		return nil, err
   486  	}
   487  
   488  	buf := &bytes.Buffer{}
   489  	gw := gzip.NewWriter(buf)
   490  	tw := tar.NewWriter(gw)
   491  	now := time.Now()
   492  
   493  	for _, filename := range filenames {
   494  		f, err := s.Open(slug, version, shasum, filename)
   495  		if err != nil {
   496  			return nil, err
   497  		}
   498  		content, err := io.ReadAll(f)
   499  		errc := f.Close()
   500  		if err != nil {
   501  			return nil, err
   502  		}
   503  		if errc != nil {
   504  			return nil, errc
   505  		}
   506  		hdr := &tar.Header{
   507  			Name:     filename,
   508  			Mode:     0640,
   509  			Size:     int64(len(content)),
   510  			Typeflag: tar.TypeReg,
   511  			ModTime:  now,
   512  		}
   513  		if err := tw.WriteHeader(hdr); err != nil {
   514  			return nil, err
   515  		}
   516  		if _, err := tw.Write(content); err != nil {
   517  			return nil, err
   518  		}
   519  	}
   520  
   521  	if err := tw.Close(); err != nil {
   522  		return nil, err
   523  	}
   524  	if err := gw.Close(); err != nil {
   525  		return nil, err
   526  	}
   527  	return buf, nil
   528  }
   529  
   530  // serveContent replies to the request using the content in the provided
   531  // reader. The Content-Length and Content-Type headers are added with the
   532  // provided values.
   533  func serveContent(w http.ResponseWriter, r *http.Request, contentType string, size int64, content io.Reader) error {
   534  	var err error
   535  
   536  	h := w.Header()
   537  	if size > 0 {
   538  		h.Set("Content-Length", strconv.FormatInt(size, 10))
   539  	}
   540  	if contentType != "" {
   541  		h.Set("Content-Type", contentType)
   542  	}
   543  	w.WriteHeader(http.StatusOK)
   544  	if r.Method != "HEAD" {
   545  		_, err = io.Copy(w, content)
   546  	}
   547  
   548  	return err
   549  }