From 59cdfc3d040fe39d0d0e5335cbd8c3e10e99f037 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 27 Dec 2018 20:16:06 -0600 Subject: [PATCH] rest/api passes --- backend/app/rest/api/admin.go | 7 +- backend/app/rest/api/admin_test.go | 4 +- backend/app/rest/api/rest.go | 13 +- backend/app/rest/api/rest_private.go | 7 +- backend/app/rest/api/rest_private_test.go | 10 +- backend/app/rest/api/rest_public_test.go | 3 +- backend/app/rest/api/rest_test.go | 57 +++--- backend/app/rest/proxy/avatar.go | 118 ------------ backend/app/rest/proxy/avatar_test.go | 172 ----------------- backend/app/rest/proxy/image.go | 4 +- backend/app/rest/user.go | 43 +++-- backend/app/store/avatar/bolt.go | 144 -------------- backend/app/store/avatar/bolt_test.go | 99 ---------- backend/app/store/avatar/gridfs.go | 125 ------------ backend/app/store/avatar/gridfs_test.go | 97 ---------- backend/app/store/avatar/localfs.go | 129 ------------- backend/app/store/avatar/localfs_test.go | 178 ------------------ backend/app/store/avatar/store.go | 110 ----------- backend/app/store/avatar/store_test.go | 109 ----------- backend/app/store/avatar/testdata/circles.jpg | Bin 23983 -> 0 bytes backend/app/store/avatar/testdata/circles.png | Bin 11392 -> 0 bytes 21 files changed, 83 insertions(+), 1346 deletions(-) delete mode 100644 backend/app/rest/proxy/avatar.go delete mode 100644 backend/app/rest/proxy/avatar_test.go delete mode 100644 backend/app/store/avatar/bolt.go delete mode 100644 backend/app/store/avatar/bolt_test.go delete mode 100644 backend/app/store/avatar/gridfs.go delete mode 100644 backend/app/store/avatar/gridfs_test.go delete mode 100644 backend/app/store/avatar/localfs.go delete mode 100644 backend/app/store/avatar/localfs_test.go delete mode 100644 backend/app/store/avatar/store.go delete mode 100644 backend/app/store/avatar/store_test.go delete mode 100644 backend/app/store/avatar/testdata/circles.jpg delete mode 100644 backend/app/store/avatar/testdata/circles.png diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 8f6c3a2f..2cfe1479 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -14,7 +14,6 @@ import ( "github.com/go-pkgz/rest/cache" "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/service" ) @@ -25,7 +24,6 @@ type admin struct { cache cache.LoadingCache authenticator auth.Service readOnlyAge int - avatarProxy *proxy.Avatar migrator *Migrator } @@ -111,7 +109,7 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[INFO] delete all user comments by request for %s, site %s", claims.User.ID, claims.Audience) // deleteme set by deleteMeCtrl, this check just to make sure we not trying to delete with leaked token - if val, err := claims.User.BoolAttr("delete_me"); err != nil || !val { + if !claims.User.BoolAttr("delete_me") { rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("forbidden"), "can't use provided token") return } @@ -122,7 +120,8 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { } if claims.User.Picture != "" { - if err := a.avatarProxy.Store.Remove(path.Base(claims.User.Picture)); err != nil { + avatartStore := a.authenticator.AvatarProxy().Store + if err := avatartStore.Remove(path.Base(claims.User.Picture)); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar") return } diff --git a/backend/app/rest/api/admin_test.go b/backend/app/rest/api/admin_test.go index a0dd5cb4..9ebb64af 100644 --- a/backend/app/rest/api/admin_test.go +++ b/backend/app/rest/api/admin_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/dgrijalva/jwt-go" + jwt "github.com/dgrijalva/jwt-go" "github.com/go-pkgz/auth/token" R "github.com/go-pkgz/rest" @@ -615,7 +615,7 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) { // try without deleteme flag badClaims2 := claims - badClaims2.User.SetBoolAttr("delete_me", true) + badClaims2.User.SetBoolAttr("delete_me", false) tkn, err = srv.Authenticator.TokenService().Token(badClaims2) assert.Nil(t, err) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil) diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index c33b29e8..a4257685 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -41,7 +41,6 @@ type Rest struct { DataService *service.DataStore Authenticator auth.Service Cache cache.LoadingCache - AvatarProxy *proxy.Avatar ImageProxy *proxy.Image CommentFormatter *store.CommentFormatter Migrator *Migrator @@ -167,7 +166,6 @@ func (s *Rest) routes() chi.Router { cache: s.Cache, authenticator: s.Authenticator, readOnlyAge: s.ReadOnlyAge, - avatarProxy: s.AvatarProxy, } corsMiddleware := cors.New(cors.Options{ @@ -219,9 +217,15 @@ func (s *Rest) routes() chi.Router { // api routes router.Route("/api/v1", func(rapi chi.Router) { - rapi.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + + rapi.Group(func(rava chi.Router) { + rava.Use(logger.New(logger.Flags(logger.None)).Handler, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil))) + rava.Mount("/avatar", avatarHandler) + }) + // open routes rapi.Group(func(ropen chi.Router) { + ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) ropen.Use(authMiddleware.Trace) ropen.Use(logger.New(logger.Flags(logger.All), logger.IPfn(ipFn)).Handler) ropen.Get("/find", s.findCommentsCtrl) @@ -241,6 +245,7 @@ func (s *Rest) routes() chi.Router { // protected routes, require auth rapi.Group(func(rauth chi.Router) { + rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) rauth.Use(authMiddleware.Auth) rauth.Use(logger.New(logger.Flags(logger.All), logger.IPfn(ipFn)).Handler) rauth.Post("/comment", s.createCommentCtrl) @@ -255,7 +260,7 @@ func (s *Rest) routes() chi.Router { }) }) - // respond to /robots.tx with the list of allowed paths + // respond to /robots.txt with the list of allowed paths router.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil))). Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) { allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", "/img", "/avatar"} diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index d0492348..a0fd94b4 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -5,18 +5,19 @@ import ( "encoding/json" "errors" "fmt" - "github.com/go-pkgz/auth/token" "log" "net/http" "strings" "time" - "github.com/dgrijalva/jwt-go" + "github.com/go-pkgz/auth/token" + + jwt "github.com/dgrijalva/jwt-go" "github.com/go-chi/chi" "github.com/go-chi/render" R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" - "github.com/hashicorp/go-multierror" + multierror "github.com/hashicorp/go-multierror" "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 84f4b543..8c4a4c7f 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -142,8 +142,8 @@ func TestRest_CreateAndGet(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "

test 123

\n\n

http://radio-t.com

\n", comment.Text) assert.Equal(t, "**test** *123*\n\n http://radio-t.com", comment.Orig) - assert.Equal(t, store.User{Name: "developer one", ID: "dev", - Picture: "/api/v1/avatar/remark.image", Admin: true, Blocked: false, IP: "dbc7c999343f003f189f70aaf52cc04443f90790"}, + assert.Equal(t, store.User{Name: "developer one", ID: "dev", Admin: true, Blocked: false, + IP: "dbc7c999343f003f189f70aaf52cc04443f90790"}, comment.User) t.Logf("%+v", comment) } @@ -336,7 +336,7 @@ func TestRest_UserAllData(t *testing.T) { ungzBody, err := ioutil.ReadAll(ungzReader) assert.NoError(t, err) assert.True(t, strings.HasPrefix(string(ungzBody), - `{"info": {"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","admin":true}, "comments":[{`)) + `{"info": {"name":"developer one","id":"dev","picture":"","admin":true}, "comments":[{`)) assert.Equal(t, 3, strings.Count(string(ungzBody), `"text":`), "3 comments inside") t.Logf("%s", string(ungzBody)) @@ -347,7 +347,7 @@ func TestRest_UserAllData(t *testing.T) { err = json.Unmarshal(ungzBody, &parsed) assert.Nil(t, err) - assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "/api/v1/avatar/remark.image", Admin: true}, parsed.Info) + assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "", Admin: true}, parsed.Info) assert.Equal(t, 3, len(parsed.Comments)) req, err = http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=radio-t", nil) @@ -387,7 +387,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) { ungzBody, err := ioutil.ReadAll(ungzReader) assert.NoError(t, err) assert.True(t, strings.HasPrefix(string(ungzBody), - `{"info": {"name":"developer one","id":"dev","picture":"/api/v1/avatar/remark.image","admin":true}, "comments":[{`)) + `{"info": {"name":"developer one","id":"dev","picture":"","admin":true}, "comments":[{`)) assert.Equal(t, 478, strings.Count(string(ungzBody), `"text":`), "478 comments inside") } diff --git a/backend/app/rest/api/rest_public_test.go b/backend/app/rest/api/rest_public_test.go index 042ba13f..9f40a7f3 100644 --- a/backend/app/rest/api/rest_public_test.go +++ b/backend/app/rest/api/rest_public_test.go @@ -289,8 +289,7 @@ func TestRest_UserInfo(t *testing.T) { user := store.User{} err := json.Unmarshal([]byte(body), &user) assert.Nil(t, err) - assert.Equal(t, store.User{Name: "developer one", ID: "dev", - Picture: "/api/v1/avatar/remark.image", Admin: true, Blocked: false, IP: ""}, user) + assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "", Admin: true, Blocked: false, IP: ""}, user) } func TestRest_Count(t *testing.T) { diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index 476b08f5..7ed3be28 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -12,8 +12,10 @@ import ( "testing" "time" - "github.com/coreos/bbolt" + bolt "github.com/coreos/bbolt" "github.com/go-pkgz/auth" + "github.com/go-pkgz/auth/avatar" + "github.com/go-pkgz/auth/token" R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" "github.com/stretchr/testify/assert" @@ -23,7 +25,6 @@ import ( "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" adminstore "github.com/umputun/remark/backend/app/store/admin" - "github.com/umputun/remark/backend/app/store/avatar" "github.com/umputun/remark/backend/app/store/engine" "github.com/umputun/remark/backend/app/store/service" ) @@ -61,8 +62,7 @@ func TestRest_GetStarted(t *testing.T) { } func TestRest_Shutdown(t *testing.T) { - srv := Rest{Authenticator: auth.Service{}, AvatarProxy: &proxy.Avatar{Store: avatar.NewLocalFS("/tmp", 300), - RoutePath: "/api/v1/avatar"}, ImageProxy: &proxy.Image{}} + srv := Rest{Authenticator: auth.Service{}, ImageProxy: &proxy.Image{}} go func() { time.Sleep(100 * time.Millisecond) @@ -91,11 +91,11 @@ func TestRest_filterComments(t *testing.T) { func TestRest_RunStaticSSLMode(t *testing.T) { srv := Rest{ - Authenticator: auth.Service{}, - AvatarProxy: &proxy.Avatar{ - Store: avatar.NewLocalFS("/tmp", 300), - RoutePath: "/api/v1/avatar", - }, + Authenticator: *auth.NewService(auth.Opts{ + AvatarStore: avatar.NewLocalFS("/tmp"), + AvatarResizeLimit: 300, + }), + ImageProxy: &proxy.Image{}, SSLConfig: SSLConfig{ SSLMode: Static, @@ -144,11 +144,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) { func TestRest_RunAutocertModeHTTPOnly(t *testing.T) { srv := Rest{ Authenticator: auth.Service{}, - AvatarProxy: &proxy.Avatar{ - Store: avatar.NewLocalFS("/tmp", 300), - RoutePath: "/api/v1/avatar", - }, - ImageProxy: &proxy.Image{}, + ImageProxy: &proxy.Image{}, SSLConfig: SSLConfig{ SSLMode: Auto, Port: 8443, @@ -193,17 +189,18 @@ func prep(t *testing.T) (srv *Rest, ts *httptest.Server) { MaxVotes: service.UnlimitedVotes, } - //DevPasswd: "password", - // Providers: nil, - // KeyStore: adminStore, - // JWTService: auth.NewJWT(adminStore, false, time.Minute, time.Hour), srv = &Rest{ - DataService: dataStore, - Authenticator: *auth.NewService(auth.Opts{}), - Cache: &cache.Nop{}, - WebRoot: "/tmp", - RemarkURL: "https://demo.remark42.com", - AvatarProxy: &proxy.Avatar{Store: avatar.NewLocalFS("/tmp", 300), RoutePath: "/api/v1/avatar"}, + DataService: dataStore, + Authenticator: *auth.NewService(auth.Opts{ + DevPasswd: "password", + SecretReader: token.SecretFunc(func(id string) (string, error) { return "secret", nil }), + AvatarStore: avatar.NewLocalFS("/tmp"), + AvatarResizeLimit: 300, + }), + Cache: &cache.Nop{}, + WebRoot: "/tmp", + RemarkURL: "https://demo.remark42.com", + ImageProxy: &proxy.Image{}, ReadOnlyAge: 10, CommentFormatter: store.NewCommentFormatter(&proxy.Image{}), @@ -257,21 +254,21 @@ func post(t *testing.T, url string, body string) (*http.Response, error) { func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string { b, err := json.Marshal(c) - assert.Nil(t, err, "can't marshal comment %+v", c) + require.Nil(t, err, "can't marshal comment %+v", c) client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b)) - assert.Nil(t, err) + require.Nil(t, err) req.SetBasicAuth("dev", "password") resp, err := client.Do(req) - assert.Nil(t, err) - assert.Equal(t, http.StatusCreated, resp.StatusCode) + require.Nil(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) b, err = ioutil.ReadAll(resp.Body) - assert.Nil(t, err) + require.Nil(t, err) crResp := R.JSON{} err = json.Unmarshal(b, &crResp) - assert.Nil(t, err) + require.Nil(t, err) time.Sleep(time.Nanosecond * 10) return crResp["id"].(string) } diff --git a/backend/app/rest/proxy/avatar.go b/backend/app/rest/proxy/avatar.go deleted file mode 100644 index 7b71bcf1..00000000 --- a/backend/app/rest/proxy/avatar.go +++ /dev/null @@ -1,118 +0,0 @@ -package proxy - -import ( - "io" - "log" - "net/http" - "strconv" - "strings" - "time" - - "github.com/go-chi/chi" - "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/rest" - "github.com/umputun/remark/backend/app/store" - "github.com/umputun/remark/backend/app/store/avatar" -) - -// Avatar provides http handler for avatars from avatar.Store -// On user login auth will call Put and it will retrieve and save picture locally. -type Avatar struct { - Store avatar.Store - RoutePath string - RemarkURL string -} - -// Put stores retrieved avatar to avatar.Store. Gets image from user info. Returns proxied url -func (p *Avatar) Put(u store.User) (avatarURL string, err error) { - - // no picture for user, try default avatar - if u.Picture == "" { - return "", errors.Errorf("no picture for %s", u.ID) - } - - // load avatar from remote location - client := http.Client{Timeout: 10 * time.Second} - var resp *http.Response - err = retry(5, time.Second, func() error { - var e error - resp, e = client.Get(u.Picture) - return e - }) - if err != nil { - return "", errors.Wrap(err, "failed to fetch avatar from the orig") - } - - defer func() { - if e := resp.Body.Close(); e != nil { - log.Printf("[WARN] can't close response body, %s", e) - } - }() - - if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("failed to get avatar from the orig, status %s", resp.Status) - } - - avatarID, err := p.Store.Put(u.ID, resp.Body) // put returns avatar base name, like 123456.image - if err != nil { - return "", err - } - - log.Printf("[DEBUG] saved avatar from %s to %s, user %q", u.Picture, avatarID, u.Name) - return p.RemarkURL + p.RoutePath + "/" + avatarID, nil -} - -// Routes returns auth routes for given provider -func (p *Avatar) Routes(middlewares ...func(http.Handler) http.Handler) (string, chi.Router) { - router := chi.NewRouter() - router.Use(middlewares...) - - // GET /123456789.image - router.Get("/{avatar}", func(w http.ResponseWriter, r *http.Request) { - - avatarID := chi.URLParam(r, "avatar") - - // enforce client-side caching - etag := `"` + p.Store.ID(avatarID) + `"` - w.Header().Set("Etag", etag) - w.Header().Set("Cache-Control", "max-age=604800") // 7 days - if match := r.Header.Get("If-None-Match"); match != "" { - if strings.Contains(match, etag) { - w.WriteHeader(http.StatusNotModified) - return - } - } - - avReader, size, err := p.Store.Get(avatarID) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load avatar") - return - } - - defer func() { - if e := avReader.Close(); e != nil { - log.Printf("[WARN] can't close avatar reader for %s, %s", avatarID, e) - } - }() - - w.Header().Set("Content-Type", "image/*") - w.Header().Set("Content-Length", strconv.Itoa(size)) - w.WriteHeader(http.StatusOK) - if _, err = io.Copy(w, avReader); err != nil { - log.Printf("[WARN] can't send response to %s, %s", r.RemoteAddr, err) - } - }) - - return p.RoutePath, router -} - -func retry(retries int, delay time.Duration, fn func() error) (err error) { - for i := 0; i < retries; i++ { - if err = fn(); err == nil { - return nil - } - time.Sleep(delay) - } - return errors.Wrap(err, "retry failed") -} diff --git a/backend/app/rest/proxy/avatar_test.go b/backend/app/rest/proxy/avatar_test.go deleted file mode 100644 index 53eba9a6..00000000 --- a/backend/app/rest/proxy/avatar_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package proxy - -import ( - "bytes" - "errors" - "fmt" - "io" - "log" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/umputun/remark/backend/app/store" - "github.com/umputun/remark/backend/app/store/avatar" -) - -func TestAvatar_Put(t *testing.T) { - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/pic.png" { - w.Header().Set("Content-Type", "image/*") - fmt.Fprint(w, "some picture bin data") - return - } - http.Error(w, "not found", http.StatusNotFound) - })) - defer ts.Close() - - p := Avatar{RoutePath: "/avatar", RemarkURL: "http://localhost:8080", Store: avatar.NewLocalFS("/tmp/avatars.test", 300)} - os.MkdirAll("/tmp/avatars.test", 0700) - defer os.RemoveAll("/tmp/avatars.test") - - u := store.User{ID: "user1", Name: "user1 name", Picture: ts.URL + "/pic.png"} - res, err := p.Put(u) - assert.NoError(t, err) - assert.Equal(t, "http://localhost:8080/avatar/b3daa77b4c04a9551b8781d03191fe098f325e67.image", res) - fi, err := os.Stat("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image") - assert.NoError(t, err) - assert.Equal(t, int64(21), fi.Size()) - - u.ID = "user2" - res, err = p.Put(u) - assert.NoError(t, err) - assert.Equal(t, "http://localhost:8080/avatar/a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", res) - fi, err = os.Stat("/tmp/avatars.test/84/a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image") - assert.NoError(t, err) - assert.Equal(t, int64(21), fi.Size()) -} - -func TestAvatar_PutFailed(t *testing.T) { - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Print("request: ", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - })) - defer ts.Close() - - p := Avatar{RoutePath: "/avatar", Store: avatar.NewLocalFS("/tmp/avatars.test", 300)} - - u := store.User{ID: "user1", Name: "user1 name"} - _, err := p.Put(u) - assert.EqualError(t, err, "no picture for user1") - - u = store.User{ID: "user1", Name: "user1 name", Picture: "http://127.0.0.1:22345/avater/pic"} - _, err = p.Put(u) - require.Error(t, err) - assert.Contains(t, err.Error(), "connect: connection refused") - - u = store.User{ID: "user1", Name: "user1 name", Picture: ts.URL + "/avatar/pic"} - _, err = p.Put(u) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get avatar from the orig") -} - -func TestAvatar_Routes(t *testing.T) { - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/pic.png" { - w.Header().Set("Content-Type", "image/*") - w.Header().Set("Custom-Header", "xyz") - fmt.Fprint(w, "some picture bin data") - return - } - http.Error(w, "not found", http.StatusNotFound) - })) - defer ts.Close() - - p := Avatar{RoutePath: "/avatar", Store: avatar.NewLocalFS("/tmp/avatars.test", 300)} - os.MkdirAll("/tmp/avatars.test", 0700) - defer os.RemoveAll("/tmp/avatars.test") - - u := store.User{ID: "user1", Name: "user1 name", Picture: ts.URL + "/pic.png"} - _, err := p.Put(u) - assert.NoError(t, err) - - // status 400 - req, err := http.NewRequest("GET", "/some_random_name.image", nil) - if err != nil { - t.Fatal(err) - } - - rr := httptest.NewRecorder() - _, routes := p.Routes() - handler := http.Handler(routes) - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusBadRequest, rr.Code) - - // status 200 - req, err = http.NewRequest("GET", "/b3daa77b4c04a9551b8781d03191fe098f325e67.image", nil) - if err != nil { - t.Fatal(err) - } - - rr = httptest.NewRecorder() - _, routes = p.Routes() - handler = http.Handler(routes) - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) - - assert.Equal(t, []string{"image/*"}, rr.HeaderMap["Content-Type"]) - assert.Equal(t, []string{"21"}, rr.HeaderMap["Content-Length"]) - assert.Equal(t, []string(nil), rr.HeaderMap["Custom-Header"], "strip all custom headers") - assert.NotNil(t, rr.HeaderMap["Etag"]) - - bb := bytes.Buffer{} - sz, err := io.Copy(&bb, rr.Body) - assert.NoError(t, err) - assert.Equal(t, int64(21), sz) - assert.Equal(t, "some picture bin data", bb.String()) - - // status 304 - req, err = http.NewRequest("GET", "/some_random_name.image", nil) - if err != nil { - t.Fatal(err) - } - req.Header.Add("If-None-Match", `"a008de0a2ccb3308b5d99ffff66436e15538f701"`) // hash of `some_random_name.image` since the file doesn't exist - - rr = httptest.NewRecorder() - _, routes = p.Routes() - handler = http.Handler(routes) - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusNotModified, rr.Code) - assert.Equal(t, []string{`"a008de0a2ccb3308b5d99ffff66436e15538f701"`}, rr.HeaderMap["Etag"]) -} - -func TestAvatar_Retry(t *testing.T) { - i := 0 - err := retry(5, time.Millisecond, func() error { - if i == 3 { - return nil - } - i++ - return errors.New("err") - }) - assert.Nil(t, err) - assert.Equal(t, 3, i) - - st := time.Now() - err = retry(5, time.Millisecond, func() error { - return errors.New("err") - }) - assert.NotNil(t, err) - assert.True(t, time.Since(st) >= time.Microsecond*5) -} diff --git a/backend/app/rest/proxy/image.go b/backend/app/rest/proxy/image.go index 284c8e13..4d33311d 100644 --- a/backend/app/rest/proxy/image.go +++ b/backend/app/rest/proxy/image.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "git.tkginternal.com/commons/pkg/repeater" + "github.com/PuerkitoBio/goquery" "github.com/go-chi/chi" "github.com/pkg/errors" @@ -52,7 +54,7 @@ func (p Image) Routes() chi.Router { client := http.Client{Timeout: 30 * time.Second} var resp *http.Response - err = retry(5, time.Second, func() error { + err = repeater.NewDefault(5, time.Second).Do(func() error { var e error resp, e = client.Get(string(src)) return e diff --git a/backend/app/rest/user.go b/backend/app/rest/user.go index 98d0092c..7727e137 100644 --- a/backend/app/rest/user.go +++ b/backend/app/rest/user.go @@ -1,15 +1,14 @@ package rest import ( - "context" - "errors" "net/http" + "github.com/go-pkgz/auth/token" + "github.com/pkg/errors" + "github.com/umputun/remark/backend/app/store" ) -type contextKey string - // MustGetUserInfo fails if can't extract user data from the request. // should be called from authed controllers only func MustGetUserInfo(r *http.Request) store.User { @@ -23,20 +22,36 @@ func MustGetUserInfo(r *http.Request) store.User { // GetUserInfo returns user from request context func GetUserInfo(r *http.Request) (user store.User, err error) { - ctx := r.Context() - if ctx == nil { - return store.User{}, errors.New("no info about user") - } - if u, ok := ctx.Value(contextKey("user")).(store.User); ok { - return u, nil + u, err := token.GetUserInfo(r) + if err != nil { + return store.User{}, errors.Wrap(err, "can't extract user info from the token") } - return store.User{}, errors.New("user can't be parsed") + return store.User{ + Name: u.Name, + ID: u.ID, + IP: u.IP, + Picture: u.Picture, + Admin: u.IsAdmin(), + Verified: u.BoolAttr("verified"), + Blocked: u.BoolAttr("blocked"), + }, nil + } // SetUserInfo sets user into request context func SetUserInfo(r *http.Request, user store.User) *http.Request { - ctx := r.Context() - ctx = context.WithValue(ctx, contextKey("user"), user) - return r.WithContext(ctx) + u := token.User{ + ID: user.ID, + Name: user.Name, + Picture: user.Picture, + IP: user.IP, + Attributes: map[string]interface{}{ + "blocked": user.Blocked, + "verified": user.Verified, + }, + } + u.SetAdmin(user.Admin) + + return token.SetUserInfo(r, u) } diff --git a/backend/app/store/avatar/bolt.go b/backend/app/store/avatar/bolt.go deleted file mode 100644 index 826a1ded..00000000 --- a/backend/app/store/avatar/bolt.go +++ /dev/null @@ -1,144 +0,0 @@ -package avatar - -import ( - "bytes" - "crypto/sha1" - "encoding/hex" - "io" - "io/ioutil" - "log" - - "github.com/coreos/bbolt" - "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/store" -) - -// BoltDB implements avatar store with bolt -// using separate db (file) with "avatars" bucket to keep image bin and "metas" bucket -// to keep sha1 of picture. avatarID (base file name) used as a key for both. -type BoltDB struct { - fileName string // full path to boltdb - resizeLimit int - db *bolt.DB -} - -const avatarsBktName = "avatars" -const metasBktName = "metas" - -// NewBoltDB makes bolt avatar store -func NewBoltDB(fileName string, options bolt.Options, resizeLimit int) (*BoltDB, error) { - db, err := bolt.Open(fileName, 0600, &options) - if err != nil { - return nil, errors.Wrapf(err, "failed to make boltdb for %s", fileName) - } - err = db.Update(func(tx *bolt.Tx) error { - if _, e := tx.CreateBucketIfNotExists([]byte(avatarsBktName)); e != nil { - return errors.Wrapf(e, "failed to create top level bucket %s", avatarsBktName) - } - _, e := tx.CreateBucketIfNotExists([]byte(metasBktName)) - return errors.Wrapf(e, "failed to create top metas bucket %s", metasBktName) - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to initialize boltdb db %q buckets", fileName) - } - return &BoltDB{db: db, fileName: fileName, resizeLimit: resizeLimit}, nil -} - -// Put avatar to bolt, key by avatarID. Trying to resize image and lso calculates sha1 of the file for ID func -func (b *BoltDB) Put(userID string, reader io.Reader) (avatar string, err error) { - id := encodeID(userID) - - // Trying to resize avatar. - if reader = resize(reader, b.resizeLimit); reader == nil { - return "", errors.New("avatar resize reader is nil") - } - - avatarID := id + imgSfx - err = b.db.Update(func(tx *bolt.Tx) error { - buf := &bytes.Buffer{} - if _, err = io.Copy(buf, reader); err != nil { - return errors.Wrapf(err, "can't read avatar %s", avatarID) - } - - if err = tx.Bucket([]byte(avatarsBktName)).Put([]byte(avatarID), buf.Bytes()); err != nil { - return errors.Wrapf(err, "can't put to bucket with %s", avatarID) - } - // store sha1 of the image - return tx.Bucket([]byte(metasBktName)).Put([]byte(avatarID), []byte(b.sha1(buf.Bytes(), avatarID))) - }) - return avatarID, err -} - -// Get avatar reader for avatar id.image, avatarID used as the direct key -func (b *BoltDB) Get(avatarID string) (reader io.ReadCloser, size int, err error) { - buf := &bytes.Buffer{} - err = b.db.View(func(tx *bolt.Tx) error { - data := tx.Bucket([]byte(avatarsBktName)).Get([]byte(avatarID)) - if data == nil { - return errors.Errorf("can't load avatar %s", avatarID) - } - size, err = buf.Write(data) - return errors.Wrapf(err, "failed to write for %s", avatarID) - }) - return ioutil.NopCloser(buf), size, err -} - -// ID returns a fingerprint of the avatar content. -func (b *BoltDB) ID(avatarID string) (id string) { - data := []byte{} - err := b.db.View(func(tx *bolt.Tx) error { - if data = tx.Bucket([]byte(metasBktName)).Get([]byte(avatarID)); data == nil { - return errors.Errorf("can't load avatar's id for %s", avatarID) - } - return nil - }) - - if err != nil { // failed to get ID, use encoded avatarID - log.Printf("[DEBUG] can't get avatar info '%s', %s", avatarID, err) - return store.EncodeID(avatarID) - } - - return string(data) -} - -// Remove avatar from bolt -func (b *BoltDB) Remove(avatarID string) (err error) { - return b.db.Update(func(tx *bolt.Tx) error { - bkt := tx.Bucket([]byte(avatarsBktName)) - if bkt.Get([]byte(avatarID)) == nil { - return errors.Errorf("avatar key not found, %s", avatarID) - } - if err = tx.Bucket([]byte(avatarsBktName)).Delete([]byte(avatarID)); err != nil { - return errors.Wrapf(err, "can't delete avatar object %s", avatarID) - } - return errors.Wrapf(tx.Bucket([]byte(metasBktName)).Delete([]byte(avatarID)), - "can't delete meta object %s", avatarID) - }) -} - -// List all avatars (ids) from metas bucket -// note: id includes .image suffix -func (b *BoltDB) List() (ids []string, err error) { - err = b.db.View(func(tx *bolt.Tx) error { - return tx.Bucket([]byte(metasBktName)).ForEach(func(k, _ []byte) error { - ids = append(ids, string(k)) - return nil - }) - }) - return ids, errors.Wrap(err, "failed to list") -} - -// Close bolt store -func (b *BoltDB) Close() error { - return errors.Wrapf(b.db.Close(), "failed to close %s", b.fileName) -} - -func (b *BoltDB) sha1(data []byte, avatarID string) (id string) { - h := sha1.New() - if _, err := h.Write(data); err != nil { - log.Printf("[DEBUG] can't apply sha1 for content of '%s', %s", avatarID, err) - return store.EncodeID(avatarID) - } - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/backend/app/store/avatar/bolt_test.go b/backend/app/store/avatar/bolt_test.go deleted file mode 100644 index b9ddac23..00000000 --- a/backend/app/store/avatar/bolt_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package avatar - -import ( - "io/ioutil" - "os" - "sort" - "strings" - "testing" - - "github.com/coreos/bbolt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var testDb = "/tmp/test-remark-avatars.db" - -func TestBoltDB_PutAndGet(t *testing.T) { - var b Store = prepBoltStore(t) - defer func() { - assert.Nil(t, b.Close()) - os.Remove(testDb) - }() - - avatar, err := b.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - - rd, size, err := b.Get(avatar) - require.Nil(t, err) - assert.Equal(t, 21, size) - data, err := ioutil.ReadAll(rd) - require.Nil(t, err) - assert.Equal(t, "some picture bin data", string(data)) - - _, _, err = b.Get("bad avatar") - assert.NotNil(t, err) - - // check IDs - assert.Equal(t, "fddae9ce556712a6ece0e8951a6e7a05c51ed6bf", b.ID(avatar)) - assert.Equal(t, "70c881d4a26984ddce795f6f71817c9cf4480e79", b.ID("aaaa"), "no data, encoded avatar id") - - l, err := b.List() - require.Nil(t, err) - assert.Equal(t, 1, len(l)) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", l[0]) -} - -func TestBoltDB_Remove(t *testing.T) { - b := prepBoltStore(t) - defer func() { - assert.Nil(t, b.Close()) - os.Remove(testDb) - }() - - assert.NotNil(t, b.Remove("no-such-thing.image")) - - avatar, err := b.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - assert.NoError(t, b.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "remove real one") - assert.NotNil(t, b.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "already removed") -} - -func TestBoltDB_List(t *testing.T) { - b := prepBoltStore(t) - defer func() { - assert.Nil(t, b.Close()) - os.Remove(testDb) - }() - - // write some avatars - _, err := b.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = b.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = b.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - l, err := b.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - r, size, err := b.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} - -// makes new boltdb, put two records -func prepBoltStore(t *testing.T) *BoltDB { - os.Remove(testDb) - boltStore, err := NewBoltDB(testDb, bolt.Options{}, 0) - require.Nil(t, err) - return boltStore -} diff --git a/backend/app/store/avatar/gridfs.go b/backend/app/store/avatar/gridfs.go deleted file mode 100644 index 6a20253e..00000000 --- a/backend/app/store/avatar/gridfs.go +++ /dev/null @@ -1,125 +0,0 @@ -package avatar - -import ( - "bytes" - "io" - "io/ioutil" - "log" - "time" - - "github.com/globalsign/mgo" - "github.com/go-pkgz/mongo" - "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/store" -) - -// NewGridFS makes gridfs (mongo) avatar store -func NewGridFS(conn *mongo.Connection, resizeLimit int) *GridFS { - return &GridFS{Connection: conn, resizeLimit: resizeLimit} -} - -// GridFS implements Store for GridFS -type GridFS struct { - Connection *mongo.Connection - resizeLimit int -} - -// Put avatar to gridfs object, try to resize -func (gf *GridFS) Put(userID string, reader io.Reader) (avatar string, err error) { - id := encodeID(userID) - err = gf.Connection.WithDB(func(dbase *mgo.Database) error { - fh, e := dbase.GridFS("fs").Create(id + imgSfx) - if e != nil { - return e - } - defer func() { - if err = fh.Close(); err != nil { - log.Printf("[WARN] can't close avatar file %v, %s", fh, err) - } - }() - - // Trying to resize avatar. - if reader = resize(reader, gf.resizeLimit); reader == nil { - return errors.New("avatar resize reader is nil") - } - _, e = io.Copy(fh, reader) - return e - }) - return id + imgSfx, err -} - -// Get avatar reader for avatar id.image -func (gf *GridFS) Get(avatar string) (reader io.ReadCloser, size int, err error) { - buf := &bytes.Buffer{} - err = gf.Connection.WithDB(func(dbase *mgo.Database) error { - fh, e := dbase.GridFS("fs").Open(avatar) - if e != nil { - return errors.Wrapf(e, "can't load avatar %s", avatar) - } - if _, e = io.Copy(buf, fh); e != nil { - return errors.Wrapf(e, "can't copy avatar %s", avatar) - } - size = int(fh.Size()) - return fh.Close() - }) - return ioutil.NopCloser(buf), size, err -} - -// ID returns a fingerprint of the avatar content. Uses MD5 because gridfs provides it directly -func (gf *GridFS) ID(avatar string) (id string) { - err := gf.Connection.WithDB(func(dbase *mgo.Database) error { - fh, e := dbase.GridFS("fs").Open(avatar) - if e != nil { - return errors.Wrapf(e, "can't open avatar %s", avatar) - } - id = fh.MD5() - return errors.Wrapf(fh.Close(), "can't close avatar") - }) - if err != nil { - log.Printf("[DEBUG] can't get file info '%s', %s", avatar, err) - return store.EncodeID(avatar) - } - return id -} - -// Remove avatar from gridfs -func (gf *GridFS) Remove(avatar string) error { - return gf.Connection.WithDB(func(dbase *mgo.Database) error { - fh, e := dbase.GridFS("fs").Open(avatar) - if e != nil { - return errors.Wrapf(e, "can't get avatar %s", avatar) - } - if e = fh.Close(); e != nil { - log.Printf("[WARN] can't close avatar %s, %s", avatar, e) - } - return dbase.GridFS("fs").Remove(avatar) - }) -} - -// List all avatars (ids) on gfs -// note: id includes .image suffix -func (gf *GridFS) List() (ids []string, err error) { - - type gfsFile struct { - UploadDate time.Time `bson:"uploadDate"` - Length int64 `bson:",minsize"` - MD5 string - Filename string `bson:",omitempty"` - } - - files := []gfsFile{} - err = gf.Connection.WithDB(func(dbase *mgo.Database) error { - return dbase.GridFS("fs").Find(nil).All(&files) - }) - - for _, f := range files { - ids = append(ids, f.Filename) - } - return ids, errors.Wrap(err, "can't list avatars") -} - -// Close gridfs does nothing but satisfies interface -func (gf *GridFS) Close() error { - return nil -} diff --git a/backend/app/store/avatar/gridfs_test.go b/backend/app/store/avatar/gridfs_test.go deleted file mode 100644 index 4ca28741..00000000 --- a/backend/app/store/avatar/gridfs_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package avatar - -import ( - "io/ioutil" - "sort" - "strings" - "testing" - - "github.com/globalsign/mgo" - "github.com/go-pkgz/mongo" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGridFS_PutAndGet(t *testing.T) { - p, skip := prepGFStore(t) - if skip { - return - } - avatar, err := p.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - - rd, size, err := p.Get(avatar) - require.Nil(t, err) - assert.Equal(t, 21, size) - data, err := ioutil.ReadAll(rd) - require.Nil(t, err) - assert.Equal(t, "some picture bin data", string(data)) - - _, _, err = p.Get("bad avatar") - assert.NotNil(t, err) - - assert.Equal(t, "8ce5568f7f9a1c9da5b897bc8642e397", p.ID(avatar)) - assert.Equal(t, "70c881d4a26984ddce795f6f71817c9cf4480e79", p.ID("aaaa"), "no data, encode avatar id") - - l, err := p.List() - require.Nil(t, err) - assert.Equal(t, 1, len(l)) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", l[0]) -} - -func TestGridFS_Remove(t *testing.T) { - p, skip := prepGFStore(t) - if skip { - return - } - - assert.NotNil(t, p.Remove("no-such-thing.image")) - - avatar, err := p.Put("user1", strings.NewReader("some picture bin data")) - require.Nil(t, err) - assert.Equal(t, "b3daa77b4c04a9551b8781d03191fe098f325e67.image", avatar) - assert.NoError(t, p.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "remove real one") - assert.NotNil(t, p.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image"), "already removed") -} - -func TestGridFS_List(t *testing.T) { - p, skip := prepGFStore(t) - if skip { - return - } - // write some avatars - _, err := p.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = p.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = p.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - l, err := p.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - r, size, err := p.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} - -func prepGFStore(t *testing.T) (Store, bool) { - conn, err := mongo.MakeTestConnection(t) - if err != nil { - return nil, true - } - _ = conn.WithCustomCollection("fs.chunks", func(coll *mgo.Collection) error { - return coll.DropCollection() - }) - _ = conn.WithCustomCollection("fs.files", func(coll *mgo.Collection) error { - return coll.DropCollection() - }) - return NewGridFS(conn, 0), false -} diff --git a/backend/app/store/avatar/localfs.go b/backend/app/store/avatar/localfs.go deleted file mode 100644 index 9b34ac3d..00000000 --- a/backend/app/store/avatar/localfs.go +++ /dev/null @@ -1,129 +0,0 @@ -package avatar - -import ( - "fmt" - "hash/crc64" - "io" - "log" - "os" - "path" - "path/filepath" - "strconv" - "strings" - "sync" - - "github.com/pkg/errors" - - "github.com/umputun/remark/backend/app/store" -) - -// LocalFS implements Store for local file system -type LocalFS struct { - storePath string - resizeLimit int - ctcTable *crc64.Table - once sync.Once -} - -// NewLocalFS makes file-system avatar store -func NewLocalFS(storePath string, resizeLimit int) *LocalFS { - return &LocalFS{storePath: storePath, resizeLimit: resizeLimit} -} - -// Put avatar for userID to file and return avatar's file name (base), like 12345678.image -// userID can be avatarID as well, in this case encoding just strip .image prefix -func (fs *LocalFS) Put(userID string, reader io.Reader) (avatar string, err error) { - id := encodeID(userID) - location := fs.location(id) // location adds partition to path - - if _, err = os.Stat(location); os.IsNotExist(err) { - if e := os.Mkdir(location, 0700); e != nil { - return "", errors.Wrapf(e, "failed to mkdir avatar location %s", location) - } - } - - avFile := path.Join(location, id+imgSfx) - fh, err := os.Create(avFile) - if err != nil { - return "", errors.Wrapf(err, "can't create file %s", avFile) - } - defer func() { - if e := fh.Close(); e != nil { - log.Printf("[WARN] can't close avatar file %s, %s", avFile, e) - } - }() - - // Trying to resize avatar. - if reader = resize(reader, fs.resizeLimit); reader == nil { - return "", errors.New("avatar resize reader is nil") - } - - if _, err = io.Copy(fh, reader); err != nil { - return "", errors.Wrapf(err, "can't save file %s", avFile) - } - return id + imgSfx, nil -} - -// Get avatar reader for avatar id.image -func (fs *LocalFS) Get(avatar string) (reader io.ReadCloser, size int, err error) { - location := fs.location(strings.TrimSuffix(avatar, imgSfx)) - avFile := path.Join(location, avatar) - fh, err := os.Open(avFile) - if err != nil { - return nil, 0, errors.Wrapf(err, "can't load avatar %s, id", avatar) - } - if fi, e := fh.Stat(); e == nil { - size = int(fi.Size()) - } - return fh, size, nil -} - -// ID returns a fingerprint of the avatar content. -func (fs *LocalFS) ID(avatar string) (id string) { - location := fs.location(strings.TrimSuffix(avatar, imgSfx)) - avFile := path.Join(location, avatar) - fi, err := os.Stat(avFile) - if err != nil { - log.Printf("[DEBUG] can't get file info '%s', %s", avFile, err) - return store.EncodeID(avatar) - } - return store.EncodeID(avatar + strconv.FormatInt(fi.ModTime().Unix(), 10)) -} - -// Remove avatar file -func (fs *LocalFS) Remove(avatar string) error { - location := fs.location(strings.TrimSuffix(avatar, imgSfx)) - avFile := path.Join(location, avatar) - return os.Remove(avFile) -} - -// List all avatars (ids) on local file system -// note: id includes .image suffix -func (fs *LocalFS) List() (ids []string, err error) { - err = filepath.Walk(fs.storePath, - func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && strings.HasSuffix(info.Name(), imgSfx) { - ids = append(ids, info.Name()) - } - return nil - }) - return ids, errors.Wrap(err, "can't list avatars") -} - -// Close gridfs does nothing but satisfies interface -func (fs *LocalFS) Close() error { - return nil -} - -// get location (directory) for user id by adding partition to final path in order to keep files -// in different subdirectories and avoid too many files in a single place. -// the end result is a full path like this - /tmp/avatars.test/92 -func (fs *LocalFS) location(id string) string { - fs.once.Do(func() { fs.ctcTable = crc64.MakeTable(crc64.ECMA) }) - checksum64 := crc64.Checksum([]byte(id), fs.ctcTable) - partition := checksum64 % 100 - return path.Join(fs.storePath, fmt.Sprintf("%02d", partition)) -} diff --git a/backend/app/store/avatar/localfs_test.go b/backend/app/store/avatar/localfs_test.go deleted file mode 100644 index 922c420f..00000000 --- a/backend/app/store/avatar/localfs_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package avatar - -import ( - "io/ioutil" - "os" - "sort" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAvatarStoreFS_Put(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - avatar, err := p.Put("user1", nil) - assert.Equal(t, "", avatar) - assert.EqualError(t, err, "avatar resize 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") - assert.NoError(t, err) - assert.Equal(t, int64(21), fi.Size()) - - avatar, err = p.Put("user2", strings.NewReader("some picture bin data 123")) - require.Nil(t, err) - assert.Equal(t, "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", avatar) - fi, err = os.Stat("/tmp/avatars.test/84/a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image") - assert.NoError(t, err) - assert.Equal(t, int64(25), fi.Size()) - - // with encoded id - avatar, err = p.Put("f1881c06eec96db9901c7bbfe41c42a3f08e9cb8.image", strings.NewReader("some picture bin data 123")) - require.Nil(t, err) - assert.Equal(t, "f1881c06eec96db9901c7bbfe41c42a3f08e9cb8.image", avatar) - fi, err = os.Stat("/tmp/avatars.test/56/f1881c06eec96db9901c7bbfe41c42a3f08e9cb8.image") - assert.NoError(t, err) - assert.Equal(t, int64(25), fi.Size()) - - // 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 = NewLocalFS("/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 TestAvatarStoreFS_Get(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test/30", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // file not exists - r, size, err := p.Get("some_random_name.image") - // nil, 0, errors.Wrapf(err, "can't load avatar %s, id") - assert.Nil(t, r) - assert.Equal(t, 0, size) - assert.EqualError(t, err, "can't load avatar some_random_name.image, id: open /tmp/avatars.test/91/some_random_name.image: no such file or directory") - // file exists - err = ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - assert.Nil(t, err) - r, size, err = p.Get("b3daa77b4c04a9551b8781d03191fe098f325e67.image") - - assert.Nil(t, err) - assert.Equal(t, 9, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "something", string(data)) -} - -func TestAvatarStoreFS_Location(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - - tbl := []struct { - id string - res string - }{ - {"abc", "/tmp/avatars.test/35"}, - {"xyz", "/tmp/avatars.test/69"}, - {"blah blah", "/tmp/avatars.test/29"}, - {"f1881c06eec96db9901c7bbfe41c42a3f08e9cb8", "/tmp/avatars.test/56"}, - } - - for i, tt := range tbl { - assert.Equal(t, tt.res, p.location(tt.id), "test #%d", i) - } -} - -func TestAvatarStoreFS_ID(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test/30", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // file not exists - id := p.ID("some_random_name.image") - assert.Equal(t, "a008de0a2ccb3308b5d99ffff66436e15538f701", id) // store.EncodeID("some_random_name.image") - // file exists - err = ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - require.NoError(t, err) - touch := time.Date(2017, 7, 14, 2, 40, 0, 0, time.UTC) // 1500000000 - err = os.Chtimes("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", touch, touch) - require.NoError(t, err) - id = p.ID("b3daa77b4c04a9551b8781d03191fe098f325e67.image") - assert.Equal(t, "325d5b451f32c2f8e7f30a9fd65bff6a42954d9a", id) // store.EncodeID("b3daa77b4c04a9551b8781d03191fe098f325e67.image1500000000") -} - -func TestAvatarStoreFS_Remove(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test/30", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - assert.NotNil(t, p.Remove("no-such-avatar"), "remove non-existing avatar") - err = ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - require.NoError(t, err) - - assert.NoError(t, p.Remove("b3daa77b4c04a9551b8781d03191fe098f325e67.image")) - _, err = os.Stat("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image") - assert.NotNil(t, err, "removed for real") - t.Log(err) -} - -func TestAvatarStoreFS_List(t *testing.T) { - p := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // write some avatars - _, err = p.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = p.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = p.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - l, err := p.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - r, size, err := p.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} - -func BenchmarkAvatarStoreFS_ID(b *testing.B) { - p := NewLocalFS("/tmp/avatars.test", 300) - os.MkdirAll("/tmp/avatars.test/30", 0700) - defer os.RemoveAll("/tmp/avatars.test") - err := ioutil.WriteFile("/tmp/avatars.test/30/b3daa77b4c04a9551b8781d03191fe098f325e67.image", []byte("something"), 0666) - require.NoError(b, err) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - p.ID("b3daa77b4c04a9551b8781d03191fe098f325e67.image") - } -} diff --git a/backend/app/store/avatar/store.go b/backend/app/store/avatar/store.go deleted file mode 100644 index eb9a3aed..00000000 --- a/backend/app/store/avatar/store.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package avatar defines store interface and implements local (fs), gridfs (mongo) and boltdb stores. -// -package avatar - -//go:generate sh -c "mockery -inpkg -name Store -print > /tmp/mock.tmp && mv /tmp/mock.tmp store_mock.go" - -import ( - "bytes" - "image" - "strings" - - // Initializing packages for supporting GIF and JPEG formats. - _ "image/gif" - _ "image/jpeg" - "image/png" - "io" - "log" - "regexp" - - "github.com/umputun/remark/backend/app/store" - "golang.org/x/image/draw" -) - -// imgSfx for avatars -const imgSfx = ".image" - -var reValidAvatarID = regexp.MustCompile(`^[a-fA-F0-9]{40}\.image$`) - -// Store defines interface to store and and load avatars -type Store interface { - Put(userID string, reader io.Reader) (avatarID string, err error) // save avatar data from the reader and return base name - Get(avatarID string) (reader io.ReadCloser, size int, err error) // load avatar via reader - ID(avatarID string) (id string) // unique id of stored avatar's data - Remove(avatarID string) error // remove avatar data - List() (ids []string, err error) // list all avatar ids - Close() error // close store -} - -// Migrate avatars between stores -func Migrate(dst Store, src Store) (int, error) { - ids, err := src.List() - if err != nil { - return 0, err - } - for _, id := range ids { - srcReader, _, err := src.Get(id) - if err != nil { - log.Printf("[WARN] can't get reader for avatar %s", id) - continue - } - if _, err = dst.Put(id, srcReader); err != nil { - log.Printf("[WARN] can't put avatar %s", id) - } - if err = srcReader.Close(); err != nil { - log.Printf("[WARN] failed to close avatar %s", id) - } - } - return len(ids), nil -} - -// resize 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 -} - -// encodeID converts string to encoded id unless already encoded and valid avatar id (with .image) passed -func encodeID(val string) string { - if reValidAvatarID.MatchString(val) { - return strings.TrimSuffix(val, imgSfx) // already encoded, strip .image - } - return store.EncodeID(val) -} diff --git a/backend/app/store/avatar/store_test.go b/backend/app/store/avatar/store_test.go deleted file mode 100644 index 377af11a..00000000 --- a/backend/app/store/avatar/store_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package avatar - -import ( - "bytes" - "image" - "io" - "io/ioutil" - "os" - "sort" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -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 dimensions 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) - } -} - -func TestAvatarStore_Migrate(t *testing.T) { - // prep localfs - plocal := NewLocalFS("/tmp/avatars.test", 300) - err := os.MkdirAll("/tmp/avatars.test", 0700) - require.NoError(t, err) - defer os.RemoveAll("/tmp/avatars.test") - - // prep gridfs - pgfs, skip := prepGFStore(t) - if skip { - return - } - - // write to localfs - _, err = plocal.Put("user1", strings.NewReader("some picture bin data 1")) - require.Nil(t, err) - _, err = plocal.Put("user2", strings.NewReader("some picture bin data 2")) - require.Nil(t, err) - _, err = plocal.Put("user3", strings.NewReader("some picture bin data 3")) - require.Nil(t, err) - - // migrate and check reported count - count, err := Migrate(pgfs, plocal) - require.NoError(t, err) - assert.Equal(t, 3, count, "all 3 recs migrated") - - // list avatars - l, err := pgfs.List() - assert.NoError(t, err) - assert.Equal(t, 3, len(l), "3 avatars listed in destination store") - sort.Strings(l) - assert.Equal(t, []string{"0b7f849446d3383546d15a480966084442cd2193.image", "a1881c06eec96db9901c7bbfe41c42a3f08e9cb4.image", "b3daa77b4c04a9551b8781d03191fe098f325e67.image"}, l) - - // try to read one of migrated avatars - r, size, err := pgfs.Get("0b7f849446d3383546d15a480966084442cd2193.image") - assert.Nil(t, err) - assert.Equal(t, 23, size) - data, err := ioutil.ReadAll(r) - assert.Nil(t, err) - assert.Equal(t, "some picture bin data 3", string(data)) -} diff --git a/backend/app/store/avatar/testdata/circles.jpg b/backend/app/store/avatar/testdata/circles.jpg deleted file mode 100644 index 2c7048c3a4b524ba2009a6820bd7fced0af45378..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23983 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 TZTWk5k^jd_ee3IFgx>!HlUQuC diff --git a/backend/app/store/avatar/testdata/circles.png b/backend/app/store/avatar/testdata/circles.png deleted file mode 100644 index fb30946d44016008b5c28eed4719f6ef6e850367..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11392 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