Implement asset.Loader
This moved a bunch of logic out of http and into the asset package, making it available for gmit too.
This commit is contained in:
parent
7872296b83
commit
5559e01343
@ -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")
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
var (
|
||||
path = strings.TrimPrefix(r.URL.Path, "/")
|
||||
buf = new(bytes.Buffer)
|
||||
)
|
||||
|
||||
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()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
43
src/post/asset/loader.go
Normal file
43
src/post/asset/loader.go
Normal file
@ -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)
|
||||
}
|
99
src/post/asset/loader_archive.go
Normal file
99
src/post/asset/loader_archive.go
Normal file
@ -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
|
||||
}
|
85
src/post/asset/loader_image.go
Normal file
85
src/post/asset/loader_image.go
Normal file
@ -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)
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user