Compare commits

..

No commits in common. "5559e0134382a141f5edabdacf1dc81f12b55c27" and "68f3215df6e2e4f345076dd5b20b9bf5867353cf" have entirely different histories.

14 changed files with 263 additions and 343 deletions

View File

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

View File

@ -13,7 +13,6 @@ import (
"github.com/mediocregopher/blog.mediocregopher.com/srv/http" "github.com/mediocregopher/blog.mediocregopher.com/srv/http"
"github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post" "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/blog.mediocregopher.com/srv/pow"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog" "github.com/mediocregopher/mediocre-go-lib/v2/mlog"
@ -101,13 +100,7 @@ func main() {
defer postSQLDB.Close() defer postSQLDB.Close()
postStore := post.NewStore(postSQLDB) postStore := post.NewStore(postSQLDB)
postAssetStore := post.NewAssetStore(postSQLDB)
postAssetStore := asset.NewStore(postSQLDB)
postAssetLoader := asset.NewStoreLoader(postAssetStore)
postAssetLoader = asset.NewArchiveLoader(postAssetLoader)
postAssetLoader = asset.NewImageLoader(postAssetLoader)
postDraftStore := post.NewDraftStore(postSQLDB) postDraftStore := post.NewDraftStore(postSQLDB)
cache := cache.New(5000) cache := cache.New(5000)
@ -117,7 +110,6 @@ func main() {
httpParams.PowManager = powMgr httpParams.PowManager = powMgr
httpParams.PostStore = postStore httpParams.PostStore = postStore
httpParams.PostAssetStore = postAssetStore httpParams.PostAssetStore = postAssetStore
httpParams.PostAssetLoader = postAssetLoader
httpParams.PostDraftStore = postDraftStore httpParams.PostDraftStore = postDraftStore
httpParams.MailingList = ml httpParams.MailingList = ml
httpParams.GeminiPublicURL = gmiParams.PublicURL httpParams.GeminiPublicURL = gmiParams.PublicURL
@ -139,7 +131,7 @@ func main() {
gmiParams.Logger = logger.WithNamespace("gmi") gmiParams.Logger = logger.WithNamespace("gmi")
gmiParams.Cache = cache gmiParams.Cache = cache
gmiParams.PostStore = postStore gmiParams.PostStore = postStore
gmiParams.PostAssetLoader = postAssetLoader gmiParams.PostAssetStore = postAssetStore
gmiParams.HTTPPublicURL = httpParams.PublicURL gmiParams.HTTPPublicURL = httpParams.PublicURL
logger.Info(ctx, "starting gmi api") logger.Info(ctx, "starting gmi api")

View File

@ -2,16 +2,13 @@
package gmi package gmi
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"mime" "mime"
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath"
"strings" "strings"
"git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
@ -19,7 +16,6 @@ import (
"github.com/mediocregopher/blog.mediocregopher.com/srv/cache" "github.com/mediocregopher/blog.mediocregopher.com/srv/cache"
"github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post" "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/mctx"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog" "github.com/mediocregopher/mediocre-go-lib/v2/mlog"
) )
@ -31,7 +27,7 @@ type Params struct {
Cache cache.Cache Cache cache.Cache
PostStore post.Store PostStore post.Store
PostAssetLoader asset.Loader PostAssetStore post.AssetStore
PublicURL *url.URL PublicURL *url.URL
ListenAddr string ListenAddr string
@ -211,20 +207,18 @@ func (a *api) assetsMiddleware() gemini.Handler {
r *gemini.Request, r *gemini.Request,
) { ) {
path := strings.TrimPrefix(r.URL.Path, "/assets/") id := path.Base(r.URL.Path)
mimeType := mime.TypeByExtension(filepath.Ext(path)) mimeType := mime.TypeByExtension(path.Ext(id))
ctx = mctx.Annotate(ctx, "assetPath", path, "mimeType", mimeType) ctx = mctx.Annotate(ctx, "assetID", id, "mimeType", mimeType)
if mimeType != "" { if mimeType != "" {
rw.SetMediaType(mimeType) rw.SetMediaType(mimeType)
} }
buf := new(bytes.Buffer) err := a.params.PostAssetStore.Get(id, rw)
err := a.params.PostAssetLoader.Load(path, buf, asset.LoadOpts{}) if errors.Is(err, post.ErrAssetNotFound) {
if errors.Is(err, asset.ErrNotFound) {
rw.WriteHeader(gemini.StatusNotFound, "Asset not found, sorry!") rw.WriteHeader(gemini.StatusNotFound, "Asset not found, sorry!")
return return
@ -233,12 +227,6 @@ func (a *api) assetsMiddleware() gemini.Handler {
rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error()) rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return 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
}
}) })
} }

View File

@ -2,17 +2,67 @@ package http
import ( import (
"bytes" "bytes"
"compress/gzip"
"errors" "errors"
"fmt" "fmt"
"image"
"image/jpeg"
"image/png"
"io"
"io/fs"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset" "github.com/mediocregopher/blog.mediocregopher.com/srv/post"
"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 { func (a *api) managePostAssetsHandler() http.Handler {
tpl := a.mustParseBasedTpl("post-assets-manage.html") tpl := a.mustParseBasedTpl("post-assets-manage.html")
@ -38,9 +88,44 @@ func (a *api) managePostAssetsHandler() http.Handler {
}) })
} }
func (a *api) getPostAssetHandler() http.Handler { type postAssetArchiveInfo struct {
path string
id string
subPath string
isGzipped bool
}
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 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) maxWidth, err := apiutil.StrToInt(r.FormValue("w"), 0)
if err != nil { if err != nil {
@ -48,37 +133,128 @@ func (a *api) getPostAssetHandler() http.Handler {
return return
} }
var ( if maxWidth == 0 {
path = strings.TrimPrefix(r.URL.Path, "/") http.ServeContent(rw, r, path, time.Time{}, from)
buf = new(bytes.Buffer)
)
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 return
}
} else if errors.Is(err, asset.ErrCannotResize) { if !isImgResizable(path) {
http.Error(rw, "Image resizing not supported", 400) apiutil.BadRequest(rw, r, fmt.Errorf("cannot resize asset %q", path))
return 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, post.ErrAssetNotFound) {
http.Error(rw, "asset not found", 404)
return
} else if err != nil { } else if err != nil {
apiutil.InternalServerError( apiutil.InternalServerError(
rw, r, fmt.Errorf("fetching asset at path %q: %w", path, err), rw, r,
fmt.Errorf("fetching archive asset with id %q: %w", info.id, err),
) )
return return
} }
http.ServeContent( var from io.Reader = buf
rw, r, path, time.Time{}, bytes.NewReader(buf.Bytes()),
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)
return
}
id := filepath.Base(r.URL.Path)
buf := new(bytes.Buffer)
err := a.params.PostAssetStore.Get(id, buf)
if errors.Is(err, post.ErrAssetNotFound) {
http.Error(rw, "Asset not found", 404)
return
} else if err != nil {
apiutil.InternalServerError(
rw, r, fmt.Errorf("fetching asset with id %q: %w", id, err),
)
return
}
a.writePostAsset(rw, r, id, bytes.NewReader(buf.Bytes()))
}) })
} }
@ -121,7 +297,7 @@ func (a *api) deletePostAssetHandler() http.Handler {
err := a.params.PostAssetStore.Delete(id) err := a.params.PostAssetStore.Delete(id)
if errors.Is(err, asset.ErrNotFound) { if errors.Is(err, post.ErrAssetNotFound) {
http.Error(rw, "Asset not found", 404) http.Error(rw, "Asset not found", 404)
return return
} else if err != nil { } else if err != nil {

View File

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

View File

@ -18,7 +18,6 @@ import (
"github.com/mediocregopher/blog.mediocregopher.com/srv/gmi" "github.com/mediocregopher/blog.mediocregopher.com/srv/gmi"
"github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" "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"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
) )
@ -50,7 +49,7 @@ func (a *api) postPreprocessFuncImage(args ...string) (string, error) {
}{ }{
ID: id, ID: id,
Descr: descr, Descr: descr,
Resizable: asset.IsImageResizable(id), Resizable: isImgResizable(id),
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)

View File

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

View File

@ -1,43 +0,0 @@
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

@ -1,99 +0,0 @@
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
}

View File

@ -1,85 +0,0 @@
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)
}
}

View File

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

View File

@ -47,7 +47,7 @@ func (s *draftStore) Set(post Post) error {
return fmt.Errorf("json marshaling tags %#v: %w", post.Tags, err) return fmt.Errorf("json marshaling tags %#v: %w", post.Tags, err)
} }
_, err = s.db.Exec( _, err = s.db.db.Exec(
`INSERT INTO post_drafts ( `INSERT INTO post_drafts (
id, title, description, tags, series, body, format 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) { func (s *draftStore) Get(page, count int) ([]Post, bool, error) {
posts, err := s.get(s.db, count+1, page*count, ``) posts, err := s.get(s.db.db, count+1, page*count, ``)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("querying post_drafts: %w", err) 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) { func (s *draftStore) GetByID(id string) (Post, error) {
posts, err := s.get(s.db, 0, 0, `WHERE p.id=?`, id) posts, err := s.get(s.db.db, 0, 0, `WHERE p.id=?`, id)
if err != nil { if err != nil {
return Post{}, fmt.Errorf("querying post_drafts: %w", err) 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 { func (s *draftStore) Delete(id string) error {
if _, err := s.db.Exec(`DELETE FROM post_drafts WHERE id = ?`, id); err != nil { if _, err := s.db.db.Exec(`DELETE FROM post_drafts WHERE id = ?`, id); err != nil {
return fmt.Errorf("deleting from post_drafts: %w", err) 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 var first bool
err := s.db.WithTx(func(tx *sql.Tx) error { err := s.db.withTx(func(tx *sql.Tx) error {
nowTS := now.Unix() nowTS := now.Unix()
@ -244,7 +244,7 @@ func (s *store) get(
func (s *store) Get(page, count int) ([]StoredPost, bool, error) { func (s *store) Get(page, count int) ([]StoredPost, bool, error) {
posts, err := s.get(s.db, count+1, page*count, ``) posts, err := s.get(s.db.db, count+1, page*count, ``)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("querying posts: %w", err) 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) { func (s *store) GetByID(id string) (StoredPost, error) {
posts, err := s.get(s.db, 0, 0, `WHERE p.id=?`, id) posts, err := s.get(s.db.db, 0, 0, `WHERE p.id=?`, id)
if err != nil { if err != nil {
return StoredPost{}, fmt.Errorf("querying posts: %w", err) 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) { func (s *store) GetBySeries(series string) ([]StoredPost, error) {
return s.get(s.db, 0, 0, `WHERE p.series=?`, series) return s.get(s.db.db, 0, 0, `WHERE p.series=?`, series)
} }
func (s *store) GetByTag(tag string) ([]StoredPost, error) { func (s *store) GetByTag(tag string) ([]StoredPost, error) {
var posts []StoredPost 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) 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) { func (s *store) GetTags() ([]string, error) {
rows, err := s.db.Query(`SELECT tag FROM post_tags GROUP BY tag`) rows, err := s.db.db.Query(`SELECT tag FROM post_tags GROUP BY tag`)
if err != nil { if err != nil {
return nil, fmt.Errorf("querying all tags: %w", err) 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 { 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 { if _, err := tx.Exec(`DELETE FROM post_tags WHERE post_id = ?`, id); err != nil {
return fmt.Errorf("deleting from post_tags: %w", err) 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 // SQLDB is a sqlite3 database which can be used by storage interfaces within
// this package. // this package.
type SQLDB struct { type SQLDB struct {
*sql.DB db *sql.DB
} }
// NewSQLDB initializes and returns a new sqlite3 database for storage // NewSQLDB initializes and returns a new sqlite3 database for storage
@ -116,14 +116,12 @@ func NewInMemSQLDB() *SQLDB {
// Close cleans up loose resources being held by the db. // Close cleans up loose resources being held by the db.
func (db *SQLDB) Close() error { func (db *SQLDB) Close() error {
return db.DB.Close() return db.db.Close()
} }
// WithTx initializes a transaction, runs the callback using it, and either func (db *SQLDB) withTx(cb func(*sql.Tx) error) error {
// 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 { if err != nil {
return fmt.Errorf("starting transaction: %w", err) return fmt.Errorf("starting transaction: %w", err)