Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Picciano
961ab48ca7 WIP: implementing asset.Loader
the image loader is implemented, working on the archive loader, I left a
note in the HTTP code with that was left off.

Once implemented then the get asset methods in both http and gmi should
use the loader directly, rather than loading from the store and doing
transforms. This way gmi will also gain the image transformations and
archive loading that http has (though I dunno if it really needs it).
2023-04-15 21:35:06 +02:00
Brian Picciano
7872296b83 Move asset store into its own package 2023-04-15 21:07:16 +02:00
13 changed files with 250 additions and 135 deletions

View File

@ -9,6 +9,7 @@ import (
cfgpkg "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
"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"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
"gopkg.in/yaml.v3"
@ -99,7 +100,7 @@ func main() {
}
{
assetStore := post.NewAssetStore(postDB)
assetStore := asset.NewStore(postDB)
setAsset := func(assetID, assetPath string) error {
assetFullPath := filepath.Join(testDataDir, assetPath)

View File

@ -13,6 +13,7 @@ import (
"github.com/mediocregopher/blog.mediocregopher.com/srv/http"
"github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset"
"github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
@ -100,7 +101,7 @@ func main() {
defer postSQLDB.Close()
postStore := post.NewStore(postSQLDB)
postAssetStore := post.NewAssetStore(postSQLDB)
postAssetStore := asset.NewStore(postSQLDB)
postDraftStore := post.NewDraftStore(postSQLDB)
cache := cache.New(5000)

View File

@ -16,6 +16,7 @@ import (
"github.com/mediocregopher/blog.mediocregopher.com/srv/cache"
"github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
"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"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
)
@ -27,7 +28,7 @@ type Params struct {
Cache cache.Cache
PostStore post.Store
PostAssetStore post.AssetStore
PostAssetStore asset.Store
PublicURL *url.URL
ListenAddr string
@ -218,7 +219,7 @@ func (a *api) assetsMiddleware() gemini.Handler {
err := a.params.PostAssetStore.Get(id, rw)
if errors.Is(err, post.ErrAssetNotFound) {
if errors.Is(err, asset.ErrNotFound) {
rw.WriteHeader(gemini.StatusNotFound, "Asset not found, sorry!")
return

View File

@ -5,64 +5,17 @@ import (
"compress/gzip"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"io/fs"
"net/http"
"path/filepath"
"strings"
"time"
"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/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,38 +41,6 @@ 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,
@ -170,7 +91,7 @@ func (a *api) handleGetPostAssetArchive(
err := a.params.PostAssetStore.Get(info.id, buf)
if errors.Is(err, post.ErrAssetNotFound) {
if errors.Is(err, asset.ErrNotFound) {
http.Error(rw, "asset not found", 404)
return
} else if err != nil {
@ -194,6 +115,8 @@ func (a *api) handleGetPostAssetArchive(
}
}
// TODO everything from here down needs to be moved to the archive loader
tarFS, err := tarfs.New(from)
if err != nil {
@ -244,7 +167,7 @@ func (a *api) getPostAssetHandler() http.Handler {
err := a.params.PostAssetStore.Get(id, buf)
if errors.Is(err, post.ErrAssetNotFound) {
if errors.Is(err, asset.ErrNotFound) {
http.Error(rw, "Asset not found", 404)
return
} else if err != nil {
@ -297,7 +220,7 @@ func (a *api) deletePostAssetHandler() http.Handler {
err := a.params.PostAssetStore.Delete(id)
if errors.Is(err, post.ErrAssetNotFound) {
if errors.Is(err, asset.ErrNotFound) {
http.Error(rw, "Asset not found", 404)
return
} else if err != nil {

View File

@ -20,6 +20,7 @@ import (
"github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
"github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset"
"github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
@ -36,7 +37,7 @@ type Params struct {
Cache cache.Cache
PostStore post.Store
PostAssetStore post.AssetStore
PostAssetStore asset.Store
PostDraftStore post.DraftStore
MailingList mailinglist.MailingList

View File

@ -1,4 +1,4 @@
package post
package asset
import (
"bytes"
@ -6,23 +6,25 @@ import (
"errors"
"fmt"
"io"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post"
)
var (
// ErrAssetNotFound is used to indicate an Asset could not be found in the
// AssetStore.
ErrAssetNotFound = errors.New("asset not found")
// ErrNotFound is used to indicate an Asset could not be found in the
// Store.
ErrNotFound = errors.New("asset not found")
)
// AssetStore implements the storage and retrieval of binary assets, which are
// Store implements the storage and retrieval of binary assets, which are
// intended to be used by posts (e.g. images).
type AssetStore interface {
type Store interface {
// Set sets the id to the contents of the given io.Reader.
Set(id string, from io.Reader) error
// Get writes the id's body to the given io.Writer, or returns
// ErrAssetNotFound.
// ErrNotFound.
Get(id string, into io.Writer) error
// Delete's the body stored for the id, if any.
@ -32,18 +34,18 @@ type AssetStore interface {
List() ([]string, error)
}
type assetStore struct {
db *sql.DB
type store struct {
db *post.SQLDB
}
// NewAssetStore initializes a new AssetStore using an existing SQLDB.
func NewAssetStore(db *SQLDB) AssetStore {
return &assetStore{
db: db.db,
// NewStore initializes a new Store using an existing SQLDB.
func NewStore(db *post.SQLDB) Store {
return &store{
db: db,
}
}
func (s *assetStore) Set(id string, from io.Reader) error {
func (s *store) Set(id string, from io.Reader) error {
body, err := io.ReadAll(from)
if err != nil {
@ -64,14 +66,14 @@ func (s *assetStore) Set(id string, from io.Reader) error {
return nil
}
func (s *assetStore) Get(id string, into io.Writer) error {
func (s *store) Get(id string, into io.Writer) error {
var body []byte
err := s.db.QueryRow(`SELECT body FROM assets WHERE id = ?`, id).Scan(&body)
if errors.Is(err, sql.ErrNoRows) {
return ErrAssetNotFound
return ErrNotFound
} else if err != nil {
return fmt.Errorf("selecting from assets: %w", err)
}
@ -83,12 +85,12 @@ func (s *assetStore) Get(id string, into io.Writer) error {
return nil
}
func (s *assetStore) Delete(id string) error {
func (s *store) Delete(id string) error {
_, err := s.db.Exec(`DELETE FROM assets WHERE id = ?`, id)
return err
}
func (s *assetStore) List() ([]string, error) {
func (s *store) List() ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM assets ORDER BY id ASC`)

View File

@ -1,30 +1,31 @@
package post
package asset
import (
"bytes"
"io"
"testing"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post"
"github.com/stretchr/testify/assert"
)
type assetTestHarness struct {
store AssetStore
type testHarness struct {
store Store
}
func newAssetTestHarness(t *testing.T) *assetTestHarness {
func newTestHarness(t *testing.T) *testHarness {
db := NewInMemSQLDB()
db := post.NewInMemSQLDB()
t.Cleanup(func() { db.Close() })
store := NewAssetStore(db)
store := NewStore(db)
return &assetTestHarness{
return &testHarness{
store: store,
}
}
func (h *assetTestHarness) assertGet(t *testing.T, exp, id string) {
func (h *testHarness) assertGet(t *testing.T, exp, id string) {
t.Helper()
buf := new(bytes.Buffer)
err := h.store.Get(id, buf)
@ -32,15 +33,15 @@ func (h *assetTestHarness) assertGet(t *testing.T, exp, id string) {
assert.Equal(t, exp, buf.String())
}
func (h *assetTestHarness) assertNotFound(t *testing.T, id string) {
func (h *testHarness) assertNotFound(t *testing.T, id string) {
t.Helper()
err := h.store.Get(id, io.Discard)
assert.ErrorIs(t, ErrAssetNotFound, err)
assert.ErrorIs(t, ErrNotFound, err)
}
func TestAssetStore(t *testing.T) {
func TestStore(t *testing.T) {
testAssetStore := func(t *testing.T, h *assetTestHarness) {
testStore := func(t *testing.T, h *testHarness) {
t.Helper()
h.assertNotFound(t, "foo")
@ -85,7 +86,7 @@ func TestAssetStore(t *testing.T) {
}
t.Run("sql", func(t *testing.T) {
h := newAssetTestHarness(t)
testAssetStore(t, h)
h := newTestHarness(t)
testStore(t, h)
})
}

43
src/post/asset/loader.go Normal file
View 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)
}

View File

@ -0,0 +1,57 @@
package asset
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"strings"
)
type archiveLoader struct {
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)
}
}
}

View File

@ -0,0 +1,83 @@
package asset
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"path/filepath"
"strings"
"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
}
}
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 !isImgResizable(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)
}
}

View File

@ -47,7 +47,7 @@ func (s *draftStore) Set(post Post) error {
return fmt.Errorf("json marshaling tags %#v: %w", post.Tags, err)
}
_, err = s.db.db.Exec(
_, err = s.db.Exec(
`INSERT INTO post_drafts (
id, title, description, tags, series, body, format
)
@ -145,7 +145,7 @@ func (s *draftStore) get(
func (s *draftStore) Get(page, count int) ([]Post, bool, error) {
posts, err := s.get(s.db.db, count+1, page*count, ``)
posts, err := s.get(s.db, count+1, page*count, ``)
if err != nil {
return nil, false, fmt.Errorf("querying post_drafts: %w", err)
@ -163,7 +163,7 @@ func (s *draftStore) Get(page, count int) ([]Post, bool, error) {
func (s *draftStore) GetByID(id string) (Post, error) {
posts, err := s.get(s.db.db, 0, 0, `WHERE p.id=?`, id)
posts, err := s.get(s.db, 0, 0, `WHERE p.id=?`, id)
if err != nil {
return Post{}, fmt.Errorf("querying post_drafts: %w", err)
@ -182,7 +182,7 @@ func (s *draftStore) GetByID(id string) (Post, error) {
func (s *draftStore) Delete(id string) error {
if _, err := s.db.db.Exec(`DELETE FROM post_drafts WHERE id = ?`, id); err != nil {
if _, err := s.db.Exec(`DELETE FROM post_drafts WHERE id = ?`, id); err != nil {
return fmt.Errorf("deleting from post_drafts: %w", err)
}

View File

@ -96,7 +96,7 @@ func (s *store) Set(post Post, now time.Time) (bool, error) {
var first bool
err := s.db.withTx(func(tx *sql.Tx) error {
err := s.db.WithTx(func(tx *sql.Tx) error {
nowTS := now.Unix()
@ -244,7 +244,7 @@ func (s *store) get(
func (s *store) Get(page, count int) ([]StoredPost, bool, error) {
posts, err := s.get(s.db.db, count+1, page*count, ``)
posts, err := s.get(s.db, count+1, page*count, ``)
if err != nil {
return nil, false, fmt.Errorf("querying posts: %w", err)
@ -262,7 +262,7 @@ func (s *store) Get(page, count int) ([]StoredPost, bool, error) {
func (s *store) GetByID(id string) (StoredPost, error) {
posts, err := s.get(s.db.db, 0, 0, `WHERE p.id=?`, id)
posts, err := s.get(s.db, 0, 0, `WHERE p.id=?`, id)
if err != nil {
return StoredPost{}, fmt.Errorf("querying posts: %w", err)
@ -280,14 +280,14 @@ func (s *store) GetByID(id string) (StoredPost, error) {
}
func (s *store) GetBySeries(series string) ([]StoredPost, error) {
return s.get(s.db.db, 0, 0, `WHERE p.series=?`, series)
return s.get(s.db, 0, 0, `WHERE p.series=?`, series)
}
func (s *store) GetByTag(tag string) ([]StoredPost, error) {
var posts []StoredPost
err := s.db.withTx(func(tx *sql.Tx) error {
err := s.db.WithTx(func(tx *sql.Tx) error {
rows, err := tx.Query(`SELECT post_id FROM post_tags WHERE tag = ?`, tag)
@ -331,7 +331,7 @@ func (s *store) GetByTag(tag string) ([]StoredPost, error) {
func (s *store) GetTags() ([]string, error) {
rows, err := s.db.db.Query(`SELECT tag FROM post_tags GROUP BY tag`)
rows, err := s.db.Query(`SELECT tag FROM post_tags GROUP BY tag`)
if err != nil {
return nil, fmt.Errorf("querying all tags: %w", err)
}
@ -355,7 +355,7 @@ func (s *store) GetTags() ([]string, error) {
func (s *store) Delete(id string) error {
return s.db.withTx(func(tx *sql.Tx) error {
return s.db.WithTx(func(tx *sql.Tx) error {
if _, err := tx.Exec(`DELETE FROM post_tags WHERE post_id = ?`, id); err != nil {
return fmt.Errorf("deleting from post_tags: %w", err)

View File

@ -78,7 +78,7 @@ var migrations = &migrate.MemoryMigrationSource{Migrations: []*migrate.Migration
// SQLDB is a sqlite3 database which can be used by storage interfaces within
// this package.
type SQLDB struct {
db *sql.DB
*sql.DB
}
// NewSQLDB initializes and returns a new sqlite3 database for storage
@ -116,12 +116,14 @@ func NewInMemSQLDB() *SQLDB {
// Close cleans up loose resources being held by the db.
func (db *SQLDB) Close() error {
return db.db.Close()
return db.DB.Close()
}
func (db *SQLDB) withTx(cb func(*sql.Tx) error) error {
// WithTx initializes a transaction, runs the callback using it, and either
// commits or rolls it back depending on if the callback returns an error.
func (db *SQLDB) WithTx(cb func(*sql.Tx) error) error {
tx, err := db.db.Begin()
tx, err := db.DB.Begin()
if err != nil {
return fmt.Errorf("starting transaction: %w", err)