diff --git a/src/cmd/mediocre-blog/main.go b/src/cmd/mediocre-blog/main.go index aff0f8e..ee09f92 100644 --- a/src/cmd/mediocre-blog/main.go +++ b/src/cmd/mediocre-blog/main.go @@ -101,7 +101,13 @@ func main() { defer postSQLDB.Close() postStore := post.NewStore(postSQLDB) + postAssetStore := asset.NewStore(postSQLDB) + + postAssetLoader := asset.NewStoreLoader(postAssetStore) + postAssetLoader = asset.NewArchiveLoader(postAssetLoader) + postAssetLoader = asset.NewImageLoader(postAssetLoader) + postDraftStore := post.NewDraftStore(postSQLDB) cache := cache.New(5000) @@ -111,6 +117,7 @@ func main() { httpParams.PowManager = powMgr httpParams.PostStore = postStore httpParams.PostAssetStore = postAssetStore + httpParams.PostAssetLoader = postAssetLoader httpParams.PostDraftStore = postDraftStore httpParams.MailingList = ml httpParams.GeminiPublicURL = gmiParams.PublicURL @@ -132,7 +139,7 @@ func main() { gmiParams.Logger = logger.WithNamespace("gmi") gmiParams.Cache = cache gmiParams.PostStore = postStore - gmiParams.PostAssetStore = postAssetStore + gmiParams.PostAssetLoader = postAssetLoader gmiParams.HTTPPublicURL = httpParams.PublicURL logger.Info(ctx, "starting gmi api") diff --git a/src/gmi/gmi.go b/src/gmi/gmi.go index 127f6b5..e0c4626 100644 --- a/src/gmi/gmi.go +++ b/src/gmi/gmi.go @@ -2,13 +2,16 @@ package gmi import ( + "bytes" "context" "errors" "fmt" + "io" "mime" "net/url" "os" "path" + "path/filepath" "strings" "git.sr.ht/~adnano/go-gemini" @@ -27,8 +30,8 @@ type Params struct { Logger *mlog.Logger Cache cache.Cache - PostStore post.Store - PostAssetStore asset.Store + PostStore post.Store + PostAssetLoader asset.Loader PublicURL *url.URL ListenAddr string @@ -208,16 +211,18 @@ func (a *api) assetsMiddleware() gemini.Handler { r *gemini.Request, ) { - id := path.Base(r.URL.Path) - mimeType := mime.TypeByExtension(path.Ext(id)) + path := strings.TrimPrefix(r.URL.Path, "/assets/") + mimeType := mime.TypeByExtension(filepath.Ext(path)) - ctx = mctx.Annotate(ctx, "assetID", id, "mimeType", mimeType) + ctx = mctx.Annotate(ctx, "assetPath", path, "mimeType", mimeType) if mimeType != "" { rw.SetMediaType(mimeType) } - err := a.params.PostAssetStore.Get(id, rw) + buf := new(bytes.Buffer) + + err := a.params.PostAssetLoader.Load(path, buf, asset.LoadOpts{}) if errors.Is(err, asset.ErrNotFound) { rw.WriteHeader(gemini.StatusNotFound, "Asset not found, sorry!") @@ -228,6 +233,12 @@ func (a *api) assetsMiddleware() gemini.Handler { rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error()) return } + + if _, err := io.Copy(rw, buf); err != nil { + a.params.Logger.Error(ctx, "error copying asset", err) + rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error()) + return + } }) } diff --git a/src/http/assets.go b/src/http/assets.go index 1f5f0d6..1a8f520 100644 --- a/src/http/assets.go +++ b/src/http/assets.go @@ -2,14 +2,8 @@ package http import ( "bytes" - "compress/gzip" "errors" "fmt" - "image" - "image/jpeg" - "image/png" - "io" - "io/fs" "net/http" "path/filepath" "strings" @@ -17,52 +11,8 @@ import ( "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset" - "github.com/omeid/go-tarfs" - "golang.org/x/image/draw" ) -func isImgResizable(path string) bool { - switch strings.ToLower(filepath.Ext(path)) { - case ".jpg", ".jpeg", ".png": - return true - default: - return false - } -} - -func resizeImage(out io.Writer, in io.Reader, maxWidth float64) error { - - img, format, err := image.Decode(in) - if err != nil { - return fmt.Errorf("decoding image: %w", err) - } - - 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(out, img, nil) - case "png": - return png.Encode(out, img) - default: - return fmt.Errorf("unknown image format %q", format) - } -} - func (a *api) managePostAssetsHandler() http.Handler { tpl := a.mustParseBasedTpl("post-assets-manage.html") @@ -88,173 +38,47 @@ func (a *api) managePostAssetsHandler() http.Handler { }) } -type postAssetArchiveInfo struct { - path string - id string - subPath string - isGzipped bool -} - -func extractPostAssetArchiveInfo(path string) (postAssetArchiveInfo, bool) { - - var info postAssetArchiveInfo - - info.path = strings.TrimPrefix(path, "/") - - info.id, info.subPath, _ = strings.Cut(info.path, "/") - - switch { - - case strings.HasSuffix(info.id, ".tar.gz"), - strings.HasSuffix(info.id, ".tgz"): - info.isGzipped = true - - case strings.HasSuffix(info.id, ".tar"): - // ok - - default: - // unsupported - return postAssetArchiveInfo{}, false - } - - return info, true -} - -func (a *api) writePostAsset( - rw http.ResponseWriter, - r *http.Request, - path string, - from io.ReadSeeker, -) { - - maxWidth, err := apiutil.StrToInt(r.FormValue("w"), 0) - if err != nil { - apiutil.BadRequest(rw, r, fmt.Errorf("invalid w parameter: %w", err)) - return - } - - if maxWidth == 0 { - http.ServeContent(rw, r, path, time.Time{}, from) - return - } - - if !isImgResizable(path) { - apiutil.BadRequest(rw, r, fmt.Errorf("cannot resize asset %q", path)) - return - } - - resizedBuf := new(bytes.Buffer) - - if err := resizeImage(resizedBuf, from, float64(maxWidth)); err != nil { - apiutil.InternalServerError( - rw, r, - fmt.Errorf( - "resizing image %q to size %d: %w", - path, maxWidth, err, - ), - ) - } - - http.ServeContent( - rw, r, path, time.Time{}, bytes.NewReader(resizedBuf.Bytes()), - ) -} - -func (a *api) handleGetPostAssetArchive( - rw http.ResponseWriter, - r *http.Request, - info postAssetArchiveInfo, -) { - - buf := new(bytes.Buffer) - - err := a.params.PostAssetStore.Get(info.id, buf) - - if errors.Is(err, asset.ErrNotFound) { - http.Error(rw, "asset not found", 404) - return - } else if err != nil { - apiutil.InternalServerError( - rw, r, - fmt.Errorf("fetching archive asset with id %q: %w", info.id, err), - ) - return - } - - var from io.Reader = buf - - if info.isGzipped { - - if from, err = gzip.NewReader(from); err != nil { - apiutil.InternalServerError( - rw, r, - fmt.Errorf("decompressing archive asset with id %q: %w", info.id, err), - ) - return - } - } - - tarFS, err := tarfs.New(from) - - if err != nil { - apiutil.InternalServerError( - rw, r, - fmt.Errorf("reading archive asset with id %q as fs: %w", info.id, err), - ) - return - } - - f, err := tarFS.Open(info.subPath) - - if errors.Is(err, fs.ErrExist) { - http.Error(rw, "Asset not found", 404) - return - - } else if err != nil { - - apiutil.InternalServerError( - rw, r, - fmt.Errorf( - "opening path %q from archive asset with id %q as fs: %w", - info.subPath, info.id, err, - ), - ) - return - } - - defer f.Close() - - a.writePostAsset(rw, r, info.path, f) -} - func (a *api) getPostAssetHandler() http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - archiveInfo, ok := extractPostAssetArchiveInfo(r.URL.Path) - - if ok { - a.handleGetPostAssetArchive(rw, r, archiveInfo) + maxWidth, err := apiutil.StrToInt(r.FormValue("w"), 0) + if err != nil { + apiutil.BadRequest(rw, r, fmt.Errorf("invalid w parameter: %w", err)) return } - id := filepath.Base(r.URL.Path) - - buf := new(bytes.Buffer) + var ( + path = strings.TrimPrefix(r.URL.Path, "/") + buf = new(bytes.Buffer) + ) - err := a.params.PostAssetStore.Get(id, buf) + err = a.params.PostAssetLoader.Load( + path, + buf, + asset.LoadOpts{ + ImageWidth: maxWidth, + }, + ) if errors.Is(err, asset.ErrNotFound) { http.Error(rw, "Asset not found", 404) return + + } else if errors.Is(err, asset.ErrCannotResize) { + http.Error(rw, "Image resizing not supported", 400) + return + } else if err != nil { apiutil.InternalServerError( - rw, r, fmt.Errorf("fetching asset with id %q: %w", id, err), + rw, r, fmt.Errorf("fetching asset at path %q: %w", path, err), ) return } - a.writePostAsset(rw, r, id, bytes.NewReader(buf.Bytes())) + http.ServeContent( + rw, r, path, time.Time{}, bytes.NewReader(buf.Bytes()), + ) }) } diff --git a/src/http/http.go b/src/http/http.go index dc2569a..ba81577 100644 --- a/src/http/http.go +++ b/src/http/http.go @@ -36,9 +36,10 @@ type Params struct { PowManager pow.Manager Cache cache.Cache - PostStore post.Store - PostAssetStore asset.Store - PostDraftStore post.DraftStore + PostStore post.Store + PostAssetStore asset.Store + PostAssetLoader asset.Loader + PostDraftStore post.DraftStore MailingList mailinglist.MailingList diff --git a/src/http/posts.go b/src/http/posts.go index 872ea89..5c7ac25 100644 --- a/src/http/posts.go +++ b/src/http/posts.go @@ -18,6 +18,7 @@ import ( "github.com/mediocregopher/blog.mediocregopher.com/srv/gmi" "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/post" + "github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" ) @@ -49,7 +50,7 @@ func (a *api) postPreprocessFuncImage(args ...string) (string, error) { }{ ID: id, Descr: descr, - Resizable: isImgResizable(id), + Resizable: asset.IsImageResizable(id), } buf := new(bytes.Buffer) 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) + } + +}