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 }