From 5559e0134382a141f5edabdacf1dc81f12b55c27 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Sat, 15 Apr 2023 21:35:06 +0200 Subject: Implement asset.Loader This moved a bunch of logic out of http and into the asset package, making it available for gmit too. --- src/post/asset/loader.go | 43 +++++++++++++++++ src/post/asset/loader_archive.go | 99 ++++++++++++++++++++++++++++++++++++++++ src/post/asset/loader_image.go | 85 ++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 src/post/asset/loader.go create mode 100644 src/post/asset/loader_archive.go create mode 100644 src/post/asset/loader_image.go (limited to 'src/post') diff --git a/src/post/asset/loader.go b/src/post/asset/loader.go new file mode 100644 index 0000000..f7a0f5e --- /dev/null +++ b/src/post/asset/loader.go @@ -0,0 +1,43 @@ +package asset + +import ( + "errors" + "io" + "path/filepath" +) + +var ( + ErrCannotResize = errors.New("cannot resize") +) + +// LoadOpts are optional parameters to Loader's Load method. Some may only apply +// to specific Loader implementations. +type LoadOpts struct { + + // ImageWidth is used by the ImageLoader to resize images on the fly. + ImageWidth int +} + +// Loader is used to load an asset and write its body into the given io.Writer. +// +// Errors: +// - ErrNotFound +// - ErrCannotResize (only if ImageLoader is used) +type Loader interface { + Load(path string, into io.Writer, opts LoadOpts) error +} + +type storeLoader struct { + store Store +} + +// NewStoreLoader returns a Loader which loads assets directly from the given +// Store, with no transformation. +func NewStoreLoader(store Store) Loader { + return &storeLoader{store} +} + +func (l *storeLoader) Load(path string, into io.Writer, opts LoadOpts) error { + id := filepath.Base(path) + return l.store.Get(id, into) +} diff --git a/src/post/asset/loader_archive.go b/src/post/asset/loader_archive.go new file mode 100644 index 0000000..1c8697f --- /dev/null +++ b/src/post/asset/loader_archive.go @@ -0,0 +1,99 @@ +package asset + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "io/fs" + "strings" + + "github.com/omeid/go-tarfs" +) + +type archiveLoader struct { + loader Loader +} + +// NewArchiveLoader wraps an existing Loader in order to support extracting +// files from within an archive file asset. +// +// For example, loading the path `foo.tgz/foo/bar.jpg` will call +// `Load("foo.tgz")`, load that archive into memory, and serve the file +// `./foo/bar.jpg` from within the archive. +func NewArchiveLoader(loader Loader) Loader { + return &archiveLoader{loader: loader} +} + +func (l *archiveLoader) Load(path string, into io.Writer, opts LoadOpts) error { + + id, subPath, ok := strings.Cut(strings.TrimPrefix(path, "/"), "/") + + if !ok { + return l.loader.Load(path, into, opts) + } + + var isGzipped bool + + switch { + + case strings.HasSuffix(id, ".tar.gz"), + strings.HasSuffix(id, ".tgz"): + isGzipped = true + + case strings.HasSuffix(id, ".tar"): + // ok + + default: + // unsupported + return l.loader.Load(path, into, opts) + } + + buf := new(bytes.Buffer) + + if err := l.loader.Load(id, buf, opts); err != nil { + return fmt.Errorf("loading archive into buffer: %w", err) + } + + var ( + from io.Reader = buf + err error + ) + + if isGzipped { + + if from, err = gzip.NewReader(from); err != nil { + return fmt.Errorf("decompressing archive asset with id %q: %w", id, err) + } + } + + tarFS, err := tarfs.New(from) + + if err != nil { + return fmt.Errorf("reading archive asset with id %q as fs: %w", id, err) + } + + f, err := tarFS.Open(subPath) + + if errors.Is(err, fs.ErrExist) { + return ErrNotFound + + } else if err != nil { + return fmt.Errorf( + "opening path %q from archive asset with id %q as fs: %w", + subPath, id, err, + ) + } + + defer f.Close() + + if _, err = io.Copy(into, f); err != nil { + return fmt.Errorf( + "reading %q from archive asset with id %q as fs: %w", + subPath, id, err, + ) + } + + return nil +} diff --git a/src/post/asset/loader_image.go b/src/post/asset/loader_image.go new file mode 100644 index 0000000..dc996a9 --- /dev/null +++ b/src/post/asset/loader_image.go @@ -0,0 +1,85 @@ +package asset + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "path/filepath" + "strings" + + "golang.org/x/image/draw" +) + +// IsImageResizable returns whether or not an image can be resized, based on its +// extension. +func IsImageResizable(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg", ".png": + return true + default: + return false + } +} + +type imageLoader struct { + loader Loader +} + +// NewImageLoader wraps an existing Loader in order to perform various +// image-related transformations on any image assets being loaded. Non-image +// assets are loaded as-is. +func NewImageLoader(loader Loader) Loader { + return &imageLoader{loader} +} + +func (l *imageLoader) Load(path string, into io.Writer, opts LoadOpts) error { + + if opts.ImageWidth == 0 { + return l.loader.Load(path, into, opts) + } + + if !IsImageResizable(path) { + return ErrCannotResize + } + + buf := new(bytes.Buffer) + + if err := l.loader.Load(path, buf, opts); err != nil { + return fmt.Errorf("loading image into buffer: %w", err) + } + + img, format, err := image.Decode(buf) + if err != nil { + return fmt.Errorf("decoding image: %w", err) + } + + maxWidth := float64(opts.ImageWidth) + imgRect := img.Bounds() + imgW, imgH := float64(imgRect.Dx()), float64(imgRect.Dy()) + + if imgW > maxWidth { + + newH := imgH * maxWidth / imgW + newImg := image.NewRGBA(image.Rect(0, 0, int(maxWidth), int(newH))) + + // Resize + draw.BiLinear.Scale( + newImg, newImg.Bounds(), img, img.Bounds(), draw.Over, nil, + ) + + img = newImg + } + + switch format { + case "jpeg": + return jpeg.Encode(into, img, nil) + case "png": + return png.Encode(into, img) + default: + return fmt.Errorf("unknown image format %q", format) + } + +} -- cgit v1.2.3