add avatar resizing #78 (#83)

* add avatar resizing #78

* remove bytes slices, use io.TeeReader #78

* resize() refactoring #78
This commit is contained in:
Anatoly Milkov
2018-06-17 02:28:00 -05:00
committed by Umputun
parent 64bfdec8b7
commit 835606c5cf
21 changed files with 8998 additions and 19 deletions
+2 -1
View File
@@ -40,6 +40,7 @@ type Opts struct {
BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"`
MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"`
AvatarStore string `long:"avatars" env:"AVATAR_STORE" default:"./var/avatars" description:"avatars location"`
AvatarRszLmt int `long:"avatars-rsz-lmt" env:"AVATAR_RSZ_LMT" default:"0" description:"max image size for resizing avatars on save"`
ImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"enable image proxy"`
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
MaxCachedItems int `long:"max-cache-items" env:"MAX_CACHE_ITEMS" default:"1000" description:"max cached items"`
@@ -139,7 +140,7 @@ func New(opts Opts) (*Application, error) {
jwtService := auth.NewJWT(opts.SecretKey, strings.HasPrefix(opts.RemarkURL, "https://"), 7*24*time.Hour)
avatarProxy := &proxy.Avatar{
Store: proxy.NewFSAvatarStore(opts.AvatarStore),
Store: proxy.NewFSAvatarStore(opts.AvatarStore, opts.AvatarRszLmt),
RoutePath: "/api/v1/avatar",
RemarkURL: strings.TrimSuffix(opts.RemarkURL, "/"),
}
+2 -2
View File
@@ -37,7 +37,7 @@ func TestRest_FileServer(t *testing.T) {
}
func TestRest_Shutdown(t *testing.T) {
srv := Rest{Authenticator: auth.Authenticator{}, AvatarProxy: &proxy.Avatar{Store: proxy.NewFSAvatarStore("/tmp"),
srv := Rest{Authenticator: auth.Authenticator{}, AvatarProxy: &proxy.Avatar{Store: proxy.NewFSAvatarStore("/tmp", 300),
RoutePath: "/api/v1/avatar"}, ImageProxy: &proxy.Image{}}
go func() {
@@ -67,7 +67,7 @@ func prep(t *testing.T) (srv *Rest, ts *httptest.Server) {
Cache: &mockCache{},
WebRoot: "/tmp",
RemarkURL: "https://demo.remark42.com",
AvatarProxy: &proxy.Avatar{Store: proxy.NewFSAvatarStore("/tmp"), RoutePath: "/api/v1/avatar"},
AvatarProxy: &proxy.Avatar{Store: proxy.NewFSAvatarStore("/tmp", 300), RoutePath: "/api/v1/avatar"},
ImageProxy: &proxy.Image{},
ReadOnlyAge: 10,
}
+62 -6
View File
@@ -1,8 +1,11 @@
package proxy
import (
"bytes"
"fmt"
"hash/crc64"
"image"
"image/png"
"io"
"log"
"os"
@@ -10,7 +13,12 @@ import (
"strings"
"sync"
// Initializing packages for supporting GIF and JPEG formats.
_ "image/gif"
_ "image/jpeg"
"github.com/pkg/errors"
"golang.org/x/image/draw"
"github.com/umputun/remark/app/store"
)
@@ -23,19 +31,19 @@ type AvatarStore interface {
// FSAvatarStore implements AvatarStore for local file system
type FSAvatarStore struct {
storePath string
ctcTable *crc64.Table
once sync.Once
storePath string
resizeLimit int
ctcTable *crc64.Table
once sync.Once
}
// NewFSAvatarStore makes file-system avatar store
func NewFSAvatarStore(storePath string) *FSAvatarStore {
return &FSAvatarStore{storePath: storePath}
func NewFSAvatarStore(storePath string, resizeLimit int) *FSAvatarStore {
return &FSAvatarStore{storePath: storePath, resizeLimit: resizeLimit}
}
// Put avatar for userID to file and return avatar's file name (base), like 12345678.image
func (fs *FSAvatarStore) Put(userID string, reader io.Reader) (avatar string, err error) {
id := store.EncodeID(userID)
location := fs.location(id) // location adds partition to path
@@ -56,6 +64,11 @@ func (fs *FSAvatarStore) Put(userID string, reader io.Reader) (avatar string, er
}
}()
// Trying to resize avatar.
if reader = resize(reader, fs.resizeLimit); reader == nil {
return "", errors.New("avatar reader is nil")
}
if _, err = io.Copy(fh, reader); err != nil {
return "", errors.Wrapf(err, "can't save file %s", avFile)
}
@@ -85,3 +98,46 @@ func (fs *FSAvatarStore) location(id string) string {
partition := checksum64 % 100
return path.Join(fs.storePath, fmt.Sprintf("%02d", partition))
}
// Resizes an image of supported format (PNG, JPG, GIF) to the size of "limit" px of the biggest side
// (width or height) preserving aspect ratio.
// Returns original reader if resizing is not needed or failed.
func resize(reader io.Reader, limit int) io.Reader {
if reader == nil {
log.Print("[WARN] avatar resize(): reader is nil")
return nil
}
if limit <= 0 {
log.Print("[DEBUG] avatar resize(): limit should be greater than 0")
return reader
}
var teeBuf bytes.Buffer
tee := io.TeeReader(reader, &teeBuf)
src, _, err := image.Decode(tee)
if err != nil {
log.Printf("[WARN] avatar resize(): can't decode avatar image, %s", err)
return &teeBuf
}
bounds := src.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w <= limit && h <= limit || w <= 0 || h <= 0 {
log.Print("[DEBUG] resizing image is smaller that the limit or has 0 size")
return &teeBuf
}
newW, newH := w*limit/h, limit
if w > h {
newW, newH = limit, h*limit/w
}
m := image.NewRGBA(image.Rect(0, 0, newW, newH))
// Slower than `draw.ApproxBiLinear.Scale()` but better quality.
draw.BiLinear.Scale(m, m.Bounds(), src, src.Bounds(), draw.Src, nil)
var out bytes.Buffer
if err = png.Encode(&out, m); err != nil {
log.Printf("[WARN] avatar resize(): can't encode resized avatar to PNG, %s", err)
return &teeBuf
}
return &out
}
+74 -5
View File
@@ -1,6 +1,9 @@
package proxy
import (
"bytes"
"image"
"io"
"io/ioutil"
"os"
"strings"
@@ -11,11 +14,15 @@ import (
)
func TestAvatarStore_Put(t *testing.T) {
p := NewFSAvatarStore("/tmp/avatars.test")
p := NewFSAvatarStore("/tmp/avatars.test", 300)
os.MkdirAll("/tmp/avatars.test", 0700)
defer os.RemoveAll("/tmp/avatars.test")
avatar, err := p.Put("user1", strings.NewReader("some picture bin data"))
avatar, err := p.Put("user1", nil)
assert.Equal(t, "", avatar)
assert.EqualError(t, err, "avatar reader is nil")
avatar, err = p.Put("user1", strings.NewReader("some picture bin data"))
require.Nil(t, err)
assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar)
fi, err := os.Stat("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image")
@@ -29,13 +36,23 @@ func TestAvatarStore_Put(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, int64(25), fi.Size())
p = NewFSAvatarStore("/dev/null")
// with resize
file, e := os.Open("testdata/circles.png")
require.Nil(t, e)
avatar, err = p.Put("user3", file)
require.Nil(t, err)
assert.Equal(t, "0b7f849446d3383546d15a480966084442cd2193.image", avatar)
fi, err = os.Stat("/tmp/avatars.test/60/0b7f849446d3383546d15a480966084442cd2193.image")
assert.NoError(t, err)
assert.Equal(t, int64(6986), fi.Size())
p = NewFSAvatarStore("/dev/null", 300)
_, err = p.Put("user1", strings.NewReader("some picture bin data"))
assert.EqualError(t, err, "can't create file /dev/null/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image: open /dev/null/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image: not a directory")
}
func TestAvatarStore_Get(t *testing.T) {
p := NewFSAvatarStore("/tmp/avatars.test")
p := NewFSAvatarStore("/tmp/avatars.test", 300)
os.MkdirAll("/tmp/avatars.test/30", 0700)
defer os.RemoveAll("/tmp/avatars.test")
ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666)
@@ -48,7 +65,7 @@ func TestAvatarStore_Get(t *testing.T) {
}
func TestAvatarStore_Location(t *testing.T) {
p := NewFSAvatarStore("/tmp/avatars.test")
p := NewFSAvatarStore("/tmp/avatars.test", 300)
tbl := []struct {
id string
@@ -63,3 +80,55 @@ func TestAvatarStore_Location(t *testing.T) {
assert.Equal(t, tt.res, p.location(tt.id), "test #%d", i)
}
}
func TestAvatarStore_resize(t *testing.T) {
checkC := func(t *testing.T, r io.Reader, cExp []byte) {
content, err := ioutil.ReadAll(r)
require.NoError(t, err)
assert.Equal(t, cExp, content)
}
// Reader is nil.
resizedR := resize(nil, 100)
// assert.EqualError(t, err, "limit should be greater than 0")
assert.Nil(t, resizedR)
// Negative limit error.
resizedR = resize(strings.NewReader("some picture bin data"), -1)
require.NotNil(t, resizedR)
checkC(t, resizedR, []byte("some picture bin data"))
// Decode error.
resizedR = resize(strings.NewReader("invalid image content"), 100)
assert.NotNil(t, resizedR)
checkC(t, resizedR, []byte("invalid image content"))
cases := []struct {
file string
wr, hr int
}{
{"testdata/circles.png", 400, 300}, // full size: 800x600 px
{"testdata/circles.jpg", 300, 400}, // full size: 600x800 px
}
for _, c := range cases {
img, err := ioutil.ReadFile(c.file)
require.Nil(t, err, "can't open test file %s", c.file)
// No need for resize, avatar dimentions are smaller than resize limit.
resizedR = resize(bytes.NewReader(img), 800)
assert.NotNilf(t, resizedR, "file %s", c.file)
checkC(t, resizedR, img)
// Resizing to half of width. Check resizedR avatar format PNG.
resizedR = resize(bytes.NewReader(img), 400)
assert.NotNilf(t, resizedR, "file %s", c.file)
imgRz, format, err := image.Decode(resizedR)
assert.Nilf(t, err, "file %s", c.file)
assert.Equalf(t, "png", format, "file %s", c.file)
bounds := imgRz.Bounds()
assert.Equalf(t, c.wr, bounds.Dx(), "file %s", c.file)
assert.Equalf(t, c.hr, bounds.Dy(), "file %s", c.file)
}
}
+3 -3
View File
@@ -30,7 +30,7 @@ func TestAvatar_Put(t *testing.T) {
}))
defer ts.Close()
p := Avatar{RoutePath: "/avatar", RemarkURL: "http://localhost:8080", Store: NewFSAvatarStore("/tmp/avatars.test")}
p := Avatar{RoutePath: "/avatar", RemarkURL: "http://localhost:8080", Store: NewFSAvatarStore("/tmp/avatars.test", 300)}
os.MkdirAll("/tmp/avatars.test", 0700)
defer os.RemoveAll("/tmp/avatars.test")
@@ -59,7 +59,7 @@ func TestAvatar_PutFailed(t *testing.T) {
}))
defer ts.Close()
p := Avatar{RoutePath: "/avatar", Store: NewFSAvatarStore("/tmp/avatars.test")}
p := Avatar{RoutePath: "/avatar", Store: NewFSAvatarStore("/tmp/avatars.test", 300)}
u := store.User{ID: "user1", Name: "user1 name"}
_, err := p.Put(u)
@@ -89,7 +89,7 @@ func TestAvatar_Routes(t *testing.T) {
}))
defer ts.Close()
p := Avatar{RoutePath: "/avatar", Store: NewFSAvatarStore("/tmp/avatars.test")}
p := Avatar{RoutePath: "/avatar", Store: NewFSAvatarStore("/tmp/avatars.test", 300)}
os.MkdirAll("/tmp/avatars.test", 0700)
defer os.RemoveAll("/tmp/avatars.test")
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB