Merge branch 'master' of github.com:umputun/remark into small-improvements

This commit is contained in:
Smolevich
2020-04-06 10:41:34 +03:00
38 changed files with 1224 additions and 288 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- name: install go
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.14
- name: test backend
run: |
+4 -1
View File
@@ -21,4 +21,7 @@ linters:
- varcheck
disable-all: true
issues:
exclude-use-default: false
exclude-use-default: false
service:
golangci-lint-version: 1.24.x
+4 -4
View File
@@ -4,10 +4,10 @@
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run:
1. docker-compose -f compose-dev-memstore.yml build
1. docker-compose -f compose-dev-memstore.yml up
1. `docker-compose -f compose-dev-memstore.yml build`
1. `docker-compose -f compose-dev-memstore.yml up`
As usual, demo site will run on http://127.0.0.1:8080/web/
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package
. In real-life usage `replace github.com/umputun/remark/backend => ../../` should not be used.
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package.
In real-life usage `replace github.com/umputun/remark/backend => ../../` should not be used.
@@ -0,0 +1,105 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
"context"
"path"
"sync"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/rs/xid"
)
// MemImage implements image.Store with memory backend
type MemImage struct {
imagesStaging map[string][]byte
images map[string][]byte
insertTime map[string]time.Time
sync.RWMutex
}
// NewMemImageStore makes admin Store in memory.
func NewMemImageStore() *MemImage {
log.Print("[DEBUG] make memory image store")
return &MemImage{
imagesStaging: map[string][]byte{},
images: map[string][]byte{},
insertTime: map[string]time.Time{},
}
}
func (m *MemImage) Save(userID string, img []byte) (id string, err error) {
id = path.Join(userID, guid())
return m.SaveWithID(id, img)
}
func (m *MemImage) SaveWithID(id string, img []byte) (string, error) {
m.Lock()
m.imagesStaging[id] = img
m.insertTime[id] = time.Now()
m.Unlock()
return id, nil
}
func (m *MemImage) Load(id string) ([]byte, error) {
m.RLock()
img, ok := m.images[id]
if !ok {
img, ok = m.imagesStaging[id]
}
m.RUnlock()
if !ok {
return nil, errors.Errorf("image %s not found", id)
}
return img, nil
}
func (m *MemImage) Commit(id string) error {
m.RLock()
img, ok := m.imagesStaging[id]
m.RUnlock()
if !ok {
return errors.Errorf("failed to commit %s, not found in staging", id)
}
m.Lock()
m.images[id] = img
m.Unlock()
return nil
}
func (m *MemImage) Cleanup(_ context.Context, ttl time.Duration) error {
var idsToRemove []string
m.RLock()
for id, t := range m.insertTime {
age := time.Since(t)
if age > ttl {
log.Printf("[INFO] remove staging image %s, age %v", id, age)
idsToRemove = append(idsToRemove, id)
}
}
m.RUnlock()
m.Lock()
for _, id := range idsToRemove {
delete(m.insertTime, id)
delete(m.imagesStaging, id)
}
m.Unlock()
return nil
}
// guid makes a globally unique id
func guid() string {
return xid.New().String()
}
@@ -0,0 +1,95 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package accessor
import (
"context"
"encoding/base64"
"io"
"io/ioutil"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
"JkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk" +
"8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAA" +
"ILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36I" +
"MRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2" +
"MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZm" +
"rSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhkt" +
"rRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0" +
"dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEj" +
"yy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67" +
"LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR" +
"11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5fe" +
"A+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fU" +
"yLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy" +
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func TestMemImage_Save(t *testing.T) {
svc := NewMemImageStore()
id, err := svc.Save("user1", []byte(gopher))
assert.NoError(t, err)
assert.Contains(t, id, "user1/")
}
func TestMemImage_SaveWithIDFail(t *testing.T) {
svc := NewMemImageStore()
id, err := svc.SaveWithID("test_id", []byte(gopher))
assert.NoError(t, err)
assert.Equal(t, id, "test_id")
}
func TestMemImage_LoadAfterSave(t *testing.T) {
svc := NewMemImageStore()
gopher, err := ioutil.ReadAll(gopherPNG())
assert.NoError(t, err)
img, err := svc.Load("test_id")
assert.EqualError(t, err, "image test_id not found")
assert.Empty(t, img)
id, err := svc.Save("user1", gopher)
assert.NoError(t, err)
img, err = svc.Load(id)
assert.NoError(t, err)
assert.Equal(t, gopher, img)
err = svc.Commit(id)
assert.NoError(t, err)
err = svc.Cleanup(context.TODO(), 0)
assert.NoError(t, err)
img, err = svc.Load(id)
assert.NoError(t, err)
assert.Equal(t, gopher, img)
}
func TestMemImage_CommitFail(t *testing.T) {
svc := NewMemImageStore()
err := svc.Commit("test_id")
assert.EqualError(t, err, "failed to commit test_id, not found in staging")
}
func TestMemImage_Cleanup(t *testing.T) {
svc := NewMemImageStore()
err := svc.Cleanup(context.TODO(), time.Minute)
assert.NoError(t, err)
}
@@ -7,16 +7,22 @@ services:
remark42:
build:
context: .
context: ../../..
dockerfile: Dockerfile
args:
- SKIP_BACKEND_TEST=true
- SKIP_FRONTEND_TEST=true
image: umputun/remark42:dev
container_name: "remark42"
hostname: "remark42"
container_name: "remark42-dev"
hostname: "remark42-dev"
restart: always
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
ports:
- "8080:8080" # primary rest server
- "8084:8084" # local oauth2 server
@@ -33,14 +39,26 @@ services:
- ADMIN_RPC_API=http://mem_store.r42:8080/cmd
- STORE_TYPE=rpc
- STORE_RPC_API=http://mem_store.r42:8080/cmd
- IMAGE_TYPE=rpc
- IMAGE_RPC_API=http://mem_store.r42:8080/cmd
volumes:
- ../../../var:/srv/var
mem_store.r42:
image: umputun/mem_store.r42
build:
context: .
context: ../../..
dockerfile: backend/_example/memory_store/Dockerfile
container_name: "mem_store.r42"
hostname: "mem_store.r42"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
environment:
- API=/cmd
- SECRET=123456
+6 -5
View File
@@ -1,14 +1,15 @@
module github.com/umputun/remark/memory_store
go 1.12
go 1.14
require (
github.com/go-pkgz/jrpc v0.1.0
github.com/go-pkgz/lgr v0.6.3
github.com/go-pkgz/lgr v0.7.0
github.com/jessevdk/go-flags v1.4.0
github.com/pkg/errors v0.8.1
github.com/stretchr/testify v1.4.0
github.com/umputun/remark/backend v1.4.0
github.com/pkg/errors v0.9.1
github.com/rs/xid v1.2.1
github.com/stretchr/testify v1.5.1
github.com/umputun/remark/backend v1.5.0
)
replace github.com/umputun/remark/backend => ../../
+8
View File
@@ -71,6 +71,8 @@ github.com/go-pkgz/jrpc v0.1.0/go.mod h1:JxZsvoBklA50DNhELVJnJ567Rt+KrMH9rR3u515
github.com/go-pkgz/lcw v0.5.0/go.mod h1:CSdQRQthxJQ4iDD4wTPPuWFbFdknJzwJ8WXu1nfxb10=
github.com/go-pkgz/lgr v0.6.3 h1:n9pGk2paBV8w/Y/FVEq5MkwDmP33dnUPKbY4CyyygwM=
github.com/go-pkgz/lgr v0.6.3/go.mod h1:hBM1NM/SoYdlrykgdgJWGrZ/TM/XaZIjRbJfx7NkMm8=
github.com/go-pkgz/lgr v0.7.0 h1:S/AAPwt/RE9a5mNJskA7dGVp+Dq6SMIW6LYjG3ITxY8=
github.com/go-pkgz/lgr v0.7.0/go.mod h1:yMgxU+GobMRJgIEbSzDKy/67W18S7qmGx/7BVL5AB8Q=
github.com/go-pkgz/repeater v1.1.3/go.mod h1:hVTavuO5x3Gxnu8zW7d6sQBfAneKV8X2FjU48kGfpKw=
github.com/go-pkgz/rest v1.4.1 h1:DmaVLPH2O7yLehrWOW0uz01d2mVHz9fBR/iuTiPRzaw=
github.com/go-pkgz/rest v1.4.1/go.mod h1:COazNj35u3RXAgQNBr6neR599tYP3URiOpsu9p0rOtk=
@@ -153,6 +155,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTK
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rakyll/statik v0.1.6/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs=
@@ -177,6 +181,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.0.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
github.com/tidwall/buntdb v1.1.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
@@ -280,6 +286,7 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
@@ -329,6 +336,7 @@ google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03/go.mod h1:n3cpQtvx
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+7 -5
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Umputun. All rights reserved.
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
@@ -41,6 +41,7 @@ func main() {
dataStore := accessor.NewMemData()
adminStore := accessor.NewMemAdminStore(opts.Secret)
imgStore := accessor.NewMemImageStore()
rpcServer := jrpc.Server{
API: opts.API,
@@ -51,12 +52,13 @@ func main() {
Logger: log.Default(),
}
srv := server.NewRPC(dataStore, adminStore, &rpcServer)
srv := server.NewRPC(dataStore, adminStore, imgStore, &rpcServer)
admRec := accessor.AdminRec{
SiteID: "remark",
IDs: []string{"dev_user"},
Email: "admin@example.com",
SiteID: "remark",
Enabled: true,
IDs: []string{"dev_user"},
Email: "admin@example.com",
}
adminStore.Set("remark", admRec)
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"context"
"encoding/base64"
"encoding/json"
"time"
"github.com/go-pkgz/jrpc"
)
func (s *RPC) imgSaveHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var req [2]string
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
img, err := base64.StdEncoding.DecodeString(req[1])
if err != nil {
return jrpc.Response{Error: err.Error()}
}
value, err := s.img.Save(req[0], img)
return jrpc.EncodeResponse(id, value, err)
}
func (s *RPC) imgSaveWithIDHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var req [2]string
if err := json.Unmarshal(params, &req); err != nil {
return jrpc.Response{Error: err.Error()}
}
img, err := base64.StdEncoding.DecodeString(req[1])
if err != nil {
return jrpc.Response{Error: err.Error()}
}
value, err := s.img.SaveWithID(req[0], img)
return jrpc.EncodeResponse(id, value, err)
}
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
return jrpc.Response{Error: err.Error()}
}
value, err := s.img.Load(fileID)
return jrpc.EncodeResponse(id, value, err)
}
func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.img.Commit(fileID)
return jrpc.EncodeResponse(id, nil, err)
}
func (s *RPC) imgCleanupHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var ttl time.Duration
if err := json.Unmarshal(params, &ttl); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.img.Cleanup(context.TODO(), ttl)
return jrpc.EncodeResponse(id, nil, err)
}
@@ -0,0 +1,138 @@
/*
* Copyright 2020 Umputun. All rights reserved.
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file.
*/
package server
import (
"context"
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"testing"
"time"
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/assert"
"github.com/umputun/remark/backend/app/store/image"
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "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"
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNGBytes() []byte {
img, _ := ioutil.ReadAll(gopherPNG())
return img
}
func TestRPC_imgSaveHndl(t *testing.T) {
_, port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
id, err := ri.Save("admin", gopherPNGBytes())
assert.NoError(t, err)
assert.Contains(t, id, "admin/", "id contains username")
err = ri.Commit(id)
assert.NoError(t, err)
}
func TestRPC_imgSaveWithIDHndl(t *testing.T) {
_, port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
id, err := ri.SaveWithID("test_id", gopherPNGBytes())
assert.NoError(t, err)
assert.Equal(t, id, "test_id")
}
func TestRPC_imgLoadHndl(t *testing.T) {
_, port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
// save
id, err := ri.Save("admin", gopherPNGBytes())
assert.NoError(t, err)
// load
img, err := ri.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
// commit
err = ri.Commit(id)
assert.NoError(t, err)
// load after commit
img, err = ri.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
assert.Equal(t, gopherPNGBytes(), img)
}
func TestRPC_imgCommitHndlFail(t *testing.T) {
_, port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
err := ri.Commit("test_id")
assert.EqualError(t, err, "failed to commit test_id, not found in staging")
}
func TestRPC_imgCleanupHndl(t *testing.T) {
_, port, teardown := prepTestStore(t)
defer teardown()
api := fmt.Sprintf("http://localhost:%d/test", port)
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
// save
id, err := ri.Save("admin", gopherPNGBytes())
assert.NoError(t, err)
// load
_, err = ri.Load(id)
assert.NoError(t, err)
// cleanup
err = ri.Cleanup(context.TODO(), time.Nanosecond)
assert.NoError(t, err)
// load after cleanup should fail
_, err = ri.Load(id)
assert.Error(t, err)
assert.Contains(t, err.Error(), "image admin/")
assert.Contains(t, err.Error(), "not found")
}
+13 -2
View File
@@ -8,6 +8,7 @@ package server
import (
"github.com/go-pkgz/jrpc"
"github.com/umputun/remark/backend/app/store/image"
"github.com/umputun/remark/backend/app/store/admin"
"github.com/umputun/remark/backend/app/store/engine"
@@ -19,11 +20,12 @@ type RPC struct {
*jrpc.Server
eng engine.Interface
adm admin.Store
img image.Store
}
// NewRPC makes RPC instance and register handlers
func NewRPC(e engine.Interface, a admin.Store, r *jrpc.Server) *RPC {
res := &RPC{eng: e, adm: a, Server: r}
func NewRPC(e engine.Interface, a admin.Store, i image.Store, r *jrpc.Server) *RPC {
res := &RPC{eng: e, adm: a, img: i, Server: r}
res.addHandlers()
return res
}
@@ -52,4 +54,13 @@ func (s *RPC) addHandlers() {
"enabled": s.admEnabledHndl,
"event": s.admEventHndl,
})
// image store handlers
s.Group("image", jrpc.HandlersGroup{
"save": s.imgSaveHndl,
"save_with_id": s.imgSaveWithIDHndl,
"load": s.imgLoadHndl,
"commit": s.imgCommitHndl,
"cleanup": s.imgCleanupHndl,
})
}
@@ -47,7 +47,8 @@ func waitForHTTPServerStart(port int) {
func prepTestStore(t *testing.T) (s *RPC, port int, teardown func()) {
mg := accessor.NewMemData()
adm := accessor.NewMemAdminStore("secret")
s = NewRPC(mg, adm, &jrpc.Server{API: "/test", Logger: jrpc.NoOpLogger})
img := accessor.NewMemImageStore()
s = NewRPC(mg, adm, img, &jrpc.Server{API: "/test", Logger: jrpc.NoOpLogger})
admRec := accessor.AdminRec{
SiteID: "test-site",
+28 -29
View File
@@ -129,7 +129,7 @@ type StoreGroup struct {
// ImageGroup defines options group for store pictures
type ImageGroup struct {
Type string `long:"type" env:"TYPE" description:"type of storage" choice:"fs" choice:"bolt" default:"fs"` // nolint
Type string `long:"type" env:"TYPE" description:"type of storage" choice:"fs" choice:"bolt" choice:"rpc" default:"fs"` // nolint
FS struct {
Path string `long:"path" env:"PATH" default:"./var/pictures" description:"images location"`
Staging string `long:"staging" env:"STAGING" default:"./var/pictures.staging" description:"staging location"`
@@ -138,9 +138,10 @@ type ImageGroup struct {
Bolt struct {
File string `long:"file" env:"FILE" default:"./var/pictures.db" description:"images bolt file location"`
} `group:"bolt" namespace:"bolt" env-namespace:"bolt"`
MaxSize int `long:"max-size" env:"MAX_SIZE" default:"5000000" description:"max size of image file"`
ResizeWidth int `long:"resize-width" env:"RESIZE_WIDTH" default:"2400" description:"width of resized image"`
ResizeHeight int `long:"resize-height" env:"RESIZE_HEIGHT" default:"900" description:"height of resized image"`
MaxSize int `long:"max-size" env:"MAX_SIZE" default:"5000000" description:"max size of image file"`
ResizeWidth int `long:"resize-width" env:"RESIZE_WIDTH" default:"2400" description:"width of resized image"`
ResizeHeight int `long:"resize-height" env:"RESIZE_HEIGHT" default:"900" description:"height of resized image"`
RPC RPCGroup `group:"rpc" namespace:"rpc" env-namespace:"RPC"`
}
// AvatarGroup defines options group for avatar params
@@ -567,39 +568,37 @@ func (s *ServerCommand) makeAvatarStore() (avatar.Store, error) {
}
func (s *ServerCommand) makePicturesStore() (*image.Service, error) {
imageServiceParams := image.ServiceParams{
ImageAPI: s.RemarkURL + "/api/v1/picture/",
TTL: 5 * s.EditDuration, // add extra time to image TTL for staging
MaxSize: s.Image.MaxSize,
MaxHeight: s.Image.ResizeHeight,
MaxWidth: s.Image.ResizeWidth,
}
switch s.Image.Type {
case "bolt":
boltImageStore, err := image.NewBoltStorage(
s.Image.Bolt.File,
s.Image.MaxSize,
s.Image.ResizeHeight,
s.Image.ResizeWidth,
bolt.Options{},
)
boltImageStore, err := image.NewBoltStorage(s.Image.Bolt.File, bolt.Options{})
if err != nil {
return nil, err
}
return &image.Service{
Store: boltImageStore,
ImageAPI: s.RemarkURL + "/api/v1/picture/",
TTL: 5 * s.EditDuration, // add extra time to image TTL for staging
}, nil
return image.NewService(boltImageStore, imageServiceParams), nil
case "fs":
if err := makeDirs(s.Image.FS.Path); err != nil {
return nil, err
}
return &image.Service{
Store: &image.FileSystem{
Location: s.Image.FS.Path,
Staging: s.Image.FS.Staging,
Partitions: s.Image.FS.Partitions,
MaxSize: s.Image.MaxSize,
MaxHeight: s.Image.ResizeHeight,
MaxWidth: s.Image.ResizeWidth,
},
ImageAPI: s.RemarkURL + "/api/v1/picture/",
TTL: 5 * s.EditDuration, // add extra time to image TTL for staging
}, nil
return image.NewService(&image.FileSystem{
Location: s.Image.FS.Path,
Staging: s.Image.FS.Staging,
Partitions: s.Image.FS.Partitions,
}, imageServiceParams), nil
case "rpc":
return image.NewService(&image.RPC{
Client: jrpc.Client{
API: s.Image.RPC.API,
Client: http.Client{Timeout: s.Image.RPC.TimeOut},
AuthUser: s.Image.RPC.AuthUser,
AuthPasswd: s.Image.RPC.AuthPassword,
}}, imageServiceParams), nil
}
return nil, errors.Errorf("unsupported pictures store type %s", s.Image.Type)
}
@@ -769,7 +768,7 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *
VerificationSubject: s.Notify.Email.VerificationSubject,
UnsubscribeURL: s.RemarkURL + "/email/unsubscribe.html",
// TODO: uncomment after #560 frontend part is ready and URL is known
//SubscribeURL: s.RemarkURL + "/subscribe.html?token=",
// SubscribeURL: s.RemarkURL + "/subscribe.html?token=",
TokenGenFn: func(userID, email, site string) (string, error) {
claims := token.Claims{
Handshake: &token.Handshake{ID: userID + "::" + email},
+3 -1
View File
@@ -37,7 +37,9 @@ func TestTelegram_New(t *testing.T) {
assert.EqualError(t, err, "unexpected telegram status code 404")
_, err = NewTelegram("no-such-thing", "remark_test", 2*time.Second, "http://127.0.0.1:4321/")
assert.EqualError(t, err, "can't initialize telegram notifications: Get http://127.0.0.1:4321/no-such-thing/getMe: dial tcp 127.0.0.1:4321: connect: connection refused")
require.Error(t, err)
assert.Contains(t, err.Error(), "can't initialize telegram notifications")
assert.Contains(t, err.Error(), "dial tcp 127.0.0.1:4321: connect: connection refused")
_, err = NewTelegram("good-token", "remark_test", 2*time.Second, "")
assert.Error(t, err, "empty api url not allowed")
+1 -1
View File
@@ -425,7 +425,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
CriticalScore: s.ScoreThresholds.Critical,
PositiveScore: s.DataService.PositiveScore,
ReadOnlyAge: s.ReadOnlyAge,
MaxImageSize: s.ImageService.Store.SizeLimit(),
MaxImageSize: s.ImageService.MaxSize,
EmailNotifications: s.EmailNotifications,
EmojiEnabled: s.EmojiEnabled,
AnonVote: s.AnonVote,
+2 -2
View File
@@ -568,14 +568,14 @@ func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
return
}
file, header, err := r.FormFile("file")
file, _, err := r.FormFile("file")
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get image file from the request", rest.ErrInternal)
return
}
defer func() { _ = file.Close() }()
id, err := s.imageService.Save(header.Filename, user.ID, file)
id, err := s.imageService.Save(user.ID, file)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't save image", rest.ErrInternal)
return
+5 -5
View File
@@ -901,13 +901,13 @@ func TestRest_CreateWithPictures(t *testing.T) {
}()
lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc)
imageService := svc.ImageService
imageService.Store = &image.FileSystem{
imageService := image.NewService(&image.FileSystem{
Staging: "/tmp/remark42/images.staging",
Location: "/tmp/remark42/images",
MaxSize: 2000,
}
imageService.TTL = 100 * time.Millisecond
}, image.ServiceParams{
TTL: 100 * time.Millisecond,
MaxSize: 2000,
})
svc.privRest.imageService = imageService
svc.ImageService = imageService
+8 -9
View File
@@ -371,15 +371,14 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
Cache: memCache,
WebRoot: tmp,
RemarkURL: "https://demo.remark42.com",
ImageService: &image.Service{
Store: &image.FileSystem{
Location: tmp + "/pics-remark42",
Partitions: 100,
MaxSize: 10000,
Staging: tmp + "/pics-remark42/staging",
},
TTL: time.Millisecond * 100,
},
ImageService: image.NewService(&image.FileSystem{
Location: tmp + "/pics-remark42",
Partitions: 100,
Staging: tmp + "/pics-remark42/staging",
}, image.ServiceParams{
TTL: 100 * time.Millisecond,
MaxSize: 10000,
}),
ImageProxy: &proxy.Image{},
ReadOnlyAge: 10,
CommentFormatter: store.NewCommentFormatter(&proxy.Image{}),
+34 -7
View File
@@ -19,6 +19,33 @@ import (
"github.com/umputun/remark/backend/app/store/image"
)
// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "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"
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNGBytes() []byte {
img, _ := ioutil.ReadAll(gopherPNG())
return img
}
func TestImage_Extract(t *testing.T) {
tbl := []struct {
@@ -81,7 +108,7 @@ func TestImage_Routes(t *testing.T) {
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "123", resp.Header["Content-Length"][0])
assert.Equal(t, "1462", resp.Header["Content-Length"][0])
assert.Equal(t, "image/*", resp.Header["Content-Type"][0])
encodedImgURL = base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/no-such-image.png"))
@@ -101,7 +128,7 @@ func TestImage_RoutesCachingImage(t *testing.T) {
CacheExternal: true,
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: &image.Service{Store: &imageStore},
ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1500}),
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
@@ -113,13 +140,13 @@ func TestImage_RoutesCachingImage(t *testing.T) {
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(imgURL))
imageStore.On("Load", mock.Anything).Once().Return(nil, nil)
imageStore.On("SaveWithID", mock.Anything, mock.Anything).Once().Run(func(args mock.Arguments) { _, _ = ioutil.ReadAll(args.Get(1).(io.Reader)) }).Return("", nil)
imageStore.On("SaveWithID", mock.Anything, mock.Anything).Once().Return("", nil)
imageStore.On("Commit", mock.Anything).Once().Return(nil)
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "123", resp.Header["Content-Length"][0])
assert.Equal(t, "1462", resp.Header["Content-Length"][0])
assert.Equal(t, "image/*", resp.Header["Content-Type"][0])
imageStore.AssertCalled(t, "Load", mock.Anything)
@@ -133,7 +160,7 @@ func TestImage_RoutesUsingCachedImage(t *testing.T) {
CacheExternal: true,
RemarkURL: "https://demo.remark42.com",
RoutePath: "/api/v1/proxy",
ImageService: &image.Service{Store: &imageStore},
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
}
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
@@ -216,9 +243,9 @@ func imgHTTPTestsServer(t *testing.T) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/image/img1.png" {
t.Log("http img request", r.URL)
w.Header().Add("Content-Length", "123")
w.Header().Add("Content-Length", "1462")
w.Header().Add("Content-Type", "image/png")
_, err := w.Write([]byte(fmt.Sprintf("%123s", "X")))
_, err := w.Write(gopherPNGBytes())
assert.NoError(t, err)
return
}
+1 -1
View File
@@ -96,6 +96,6 @@ func testServer(t *testing.T, req, resp string) *httptest.Server {
require.NoError(t, err)
assert.Equal(t, req, string(body))
t.Logf("req: %s", string(body))
fmt.Fprintf(w, resp)
_, _ = fmt.Fprint(w, resp)
}))
}
+14 -33
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/binary"
"io"
"path"
"time"
@@ -21,15 +20,12 @@ const insertTimeBktName = "insertTimestamps"
// It uses 3 buckets to manage images data.
// Two buckets contains image data (staged and committed images). Third bucket holds insertion timestamps.
type Bolt struct {
fileName string
db *bolt.DB
MaxSize int
MaxHeight int
MaxWidth int
fileName string
db *bolt.DB
}
// NewBoltStorage create bolt image store
func NewBoltStorage(fileName string, maxSize int, maxHeight int, maxWidth int, options bolt.Options) (*Bolt, error) {
func NewBoltStorage(fileName string, options bolt.Options) (*Bolt, error) {
db, err := bolt.Open(fileName, 0600, &options)
if err != nil {
return nil, errors.Wrapf(err, "failed to make boltdb for %s", fileName)
@@ -51,44 +47,34 @@ func NewBoltStorage(fileName string, maxSize int, maxHeight int, maxWidth int, o
return nil, errors.Wrapf(err, "failed to initialize boltdb db %q buckets", fileName)
}
return &Bolt{
db: db,
fileName: fileName,
MaxSize: maxSize,
MaxHeight: maxHeight,
MaxWidth: maxWidth,
db: db,
fileName: fileName,
}, nil
}
// SaveWithID saves data from a reader, for given id
func (b *Bolt) SaveWithID(id string, r io.Reader) (string, error) {
data, err := readAndValidateImage(r, b.MaxSize)
if err != nil {
return "", errors.Wrapf(err, "can't load image with ID %s", id)
}
data = resize(data, b.MaxWidth, b.MaxHeight)
err = b.db.Update(func(tx *bolt.Tx) error {
if err = tx.Bucket([]byte(imagesStagedBktName)).Put([]byte(id), data); err != nil {
func (b *Bolt) SaveWithID(id string, img []byte) (string, error) {
err := b.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket([]byte(imagesStagedBktName)).Put([]byte(id), img); err != nil {
return errors.Wrapf(err, "can't put to bucket with %s", id)
}
tsBuf := &bytes.Buffer{}
if err = binary.Write(tsBuf, binary.LittleEndian, time.Now().UnixNano()); err != nil {
if err := binary.Write(tsBuf, binary.LittleEndian, time.Now().UnixNano()); err != nil {
return errors.Wrapf(err, "can't serialize timestamp for %s", id)
}
if err = tx.Bucket([]byte(insertTimeBktName)).Put([]byte(id), tsBuf.Bytes()); err != nil {
if err := tx.Bucket([]byte(insertTimeBktName)).Put([]byte(id), tsBuf.Bytes()); err != nil {
return errors.Wrapf(err, "can't put to bucket with %s", id)
}
return err
return nil
})
return id, err
}
// Save data from reader to staging bucket in DB
func (b *Bolt) Save(_ string, userID string, r io.Reader) (id string, err error) {
func (b *Bolt) Save(userID string, img []byte) (id string, err error) {
id = path.Join(userID, guid())
return b.SaveWithID(id, r)
return b.SaveWithID(id, img)
}
// Commit file stored in staging bucket by copying it to permanent bucket
@@ -130,7 +116,7 @@ func (b *Bolt) Cleanup(_ context.Context, ttl time.Duration) error {
err := b.db.Update(func(tx *bolt.Tx) error {
c := tx.Bucket([]byte(insertTimeBktName)).Cursor()
idsToRemove := [][]byte{}
var idsToRemove [][]byte
for id, tsData := c.First(); id != nil; id, tsData = c.Next() {
var ts int64
@@ -161,8 +147,3 @@ func (b *Bolt) Cleanup(_ context.Context, ttl time.Duration) error {
})
return err
}
// SizeLimit returns max size of allowed image
func (b *Bolt) SizeLimit() int {
return b.MaxSize
}
+5 -4
View File
@@ -18,7 +18,7 @@ func TestBoltStore_SaveCommit(t *testing.T) {
svc, teardown := prepareBoltImageStorageTest(t)
defer teardown()
id, err := svc.Save("file1.png", "user1", gopherPNG())
id, err := svc.Save("user1", gopherPNGBytes())
assert.NoError(t, err)
assert.Contains(t, id, "user1")
t.Log(id)
@@ -48,7 +48,7 @@ func TestBoltStore_LoadAfterSave(t *testing.T) {
svc, teardown := prepareBoltImageStorageTest(t)
defer teardown()
id, err := svc.Save("file1.png", "user1", gopherPNG())
id, err := svc.Save("user1", gopherPNGBytes())
assert.NoError(t, err)
assert.Contains(t, id, "user1")
t.Log(id)
@@ -56,6 +56,7 @@ func TestBoltStore_LoadAfterSave(t *testing.T) {
data, err := svc.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(data))
assert.Equal(t, gopherPNGBytes(), data)
_, err = svc.Load("abcd")
assert.Error(t, err)
@@ -66,7 +67,7 @@ func TestBoltStore_Cleanup(t *testing.T) {
defer teardown()
save := func(file string, user string) (id string) {
id, err := svc.Save(file, user, gopherPNG())
id, err := svc.Save(user, gopherPNGBytes())
require.NoError(t, err)
checkBoltImgData(t, svc.db, imagesStagedBktName, id, func(data []byte) error {
@@ -133,7 +134,7 @@ func prepareBoltImageStorageTest(t *testing.T) (svc *Bolt, teardown func()) {
loc, err := ioutil.TempDir("", "test_image_r42")
require.NoError(t, err, "failed to make temp dir")
svc, err = NewBoltStorage(path.Join(loc, "picture.db"), 1500, 0, 0, bolt.Options{})
svc, err = NewBoltStorage(path.Join(loc, "picture.db"), bolt.Options{})
assert.NoError(t, err, "new bolt storage")
teardown = func() {
+7 -26
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"hash/crc64"
"io"
"io/ioutil"
"math"
"os"
@@ -23,10 +22,7 @@ import (
type FileSystem struct {
Location string
Staging string
MaxSize int
Partitions int
MaxHeight int
MaxWidth int
crc struct {
*crc64.Table
@@ -37,36 +33,26 @@ type FileSystem struct {
}
// SaveWithID saves data from a reader, with given id
func (f *FileSystem) SaveWithID(id string, r io.Reader) (string, error) {
data, err := readAndValidateImage(r, f.MaxSize)
if err != nil {
return "", errors.Wrapf(err, "can't load image with ID %s", id)
}
data = resize(data, f.MaxWidth, f.MaxHeight)
func (f *FileSystem) SaveWithID(id string, img []byte) (string, error) {
dst := f.location(f.Staging, id)
if err = os.MkdirAll(path.Dir(dst), 0700); err != nil {
if err := os.MkdirAll(path.Dir(dst), 0700); err != nil {
return "", errors.Wrap(err, "can't make image directory")
}
if err = ioutil.WriteFile(dst, data, 0600); err != nil {
if err := ioutil.WriteFile(dst, img, 0600); err != nil {
return "", errors.Wrapf(err, "can't write image file with id %s", id)
}
log.Printf("[DEBUG] file %s saved for image %s, size=%d", dst, id, len(data))
log.Printf("[DEBUG] file %s saved for image %s, size=%d", dst, id, len(img))
return id, nil
}
// Save data from a reader for given file name to local FS, staging directory. Returns id as user/uuid
// Save data from a reader to local FS, staging directory. Returns id as user/uuid
// Files partitioned across multiple subdirectories, and the final path includes part, i.e. /location/user1/03/123-4567
func (f *FileSystem) Save(fileName string, userID string, r io.Reader) (id string, err error) {
func (f *FileSystem) Save(userID string, img []byte) (id string, err error) {
tempId := path.Join(userID, guid()) // make id as user/uuid
id, err = f.SaveWithID(tempId, r)
if err != nil {
err = errors.Wrapf(err, "can't save image file %s", fileName)
}
return id, err
return f.SaveWithID(tempId, img)
}
// Commit file stored in staging location by moving it to permanent location
@@ -135,11 +121,6 @@ func (f *FileSystem) Cleanup(_ context.Context, ttl time.Duration) error {
return errors.Wrap(err, "failed to cleanup images")
}
// SizeLimit returns max size of allowed image
func (f *FileSystem) SizeLimit() int {
return f.MaxSize
}
// location gets full path for id by adding partition to the 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/images/user1/92/xxx-yyy.png.
+16 -67
View File
@@ -39,12 +39,16 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNGBytes() []byte {
img, _ := ioutil.ReadAll(gopherPNG())
return img
}
func TestFsStore_Save(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
id, err := svc.Save("file1.png", "user1", gopherPNG())
id, err := svc.Save("user1", gopherPNGBytes())
assert.NoError(t, err)
assert.Contains(t, id, "user1/")
t.Log(id)
@@ -56,78 +60,32 @@ func TestFsStore_Save(t *testing.T) {
assert.Equal(t, 1462, len(data))
}
func TestFsStore_SaveWithResize(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
svc.MaxWidth, svc.MaxHeight = 32, 32
id, err := svc.Save("file1.png", "user1", gopherPNG())
assert.NoError(t, err)
assert.Contains(t, id, "user1/")
t.Log(id)
img := svc.location(svc.Staging, id)
t.Log(img)
data, err := ioutil.ReadFile(img)
assert.NoError(t, err)
assert.Equal(t, 1135, len(data))
}
func TestFsStore_SaveWithResizeJpeg(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
svc.MaxWidth, svc.MaxHeight = 400, 300
svc.MaxSize = 32000
fh, err := os.Open("testdata/circles.jpg")
defer func() { assert.NoError(t, fh.Close()) }()
assert.NoError(t, err)
id, err := svc.Save("circles.jpg", "user1", fh)
assert.NoError(t, err)
assert.Contains(t, id, "user1/")
t.Log(id)
img := svc.location(svc.Staging, id)
t.Log(img)
data, err := ioutil.ReadFile(img)
assert.NoError(t, err)
assert.Equal(t, 10918, len(data))
}
func TestFsStore_SaveNoResizeJpeg(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
svc.MaxWidth, svc.MaxHeight = 1400, 1300
svc.MaxSize = 32000
fh, err := os.Open("testdata/circles.jpg")
defer func() { assert.NoError(t, fh.Close()) }()
assert.NoError(t, err)
id, err := svc.Save("circles.jpg", "user1", fh)
img, err := ioutil.ReadAll(fh)
assert.NoError(t, err)
id, err := svc.Save("user1", img)
assert.NoError(t, err)
assert.Contains(t, id, "user1/")
t.Log(id)
img := svc.location(svc.Staging, id)
t.Log(img)
data, err := ioutil.ReadFile(img)
imgPath := svc.location(svc.Staging, id)
t.Log(imgPath)
data, err := ioutil.ReadFile(imgPath)
assert.NoError(t, err)
assert.Equal(t, 23983, len(data))
}
func TestFsStore_WrongFormat(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
_, err := svc.Save("file1.png", "user1", strings.NewReader("blah blah bad image"))
assert.Error(t, err)
}
func TestFsStore_SaveAndCommit(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
id, err := svc.Save("file1.png", "user1", gopherPNG())
id, err := svc.Save("user1", gopherPNGBytes())
require.NoError(t, err)
err = svc.Commit(id)
require.NoError(t, err)
@@ -143,27 +101,19 @@ func TestFsStore_SaveAndCommit(t *testing.T) {
assert.Equal(t, 1462, len(data))
}
func TestFsStore_SaveTooLarge(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
svc.MaxSize = 2000
_, err := svc.Save("blah_ff1.png", "user2", io.MultiReader(gopherPNG(), gopherPNG()))
assert.Error(t, err)
assert.Contains(t, err.Error(), "is too large")
}
func TestFsStore_LoadAfterSave(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
id, err := svc.Save("blah_ff1.png", "user1", gopherPNG())
id, err := svc.Save("user1", gopherPNGBytes())
assert.NoError(t, err)
t.Log(id)
data, err := svc.Load(id)
assert.NoError(t, err)
assert.Equal(t, 1462, len(data))
assert.Equal(t, gopherPNGBytes(), data)
_, err = svc.Load("abcd")
assert.Error(t, err)
}
@@ -173,7 +123,7 @@ func TestFsStore_LoadAfterCommit(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()
id, err := svc.Save("blah_ff1.png", "user1", gopherPNG())
id, err := svc.Save("user1", gopherPNGBytes())
assert.NoError(t, err)
t.Log(id)
err = svc.Commit(id)
@@ -235,7 +185,7 @@ func TestFsStore_Cleanup(t *testing.T) {
defer teardown()
save := func(file string, user string) (path string) {
id, err := svc.Save(file, user, gopherPNG())
id, err := svc.Save(user, gopherPNGBytes())
require.NoError(t, err)
img := svc.location(svc.Staging, id)
data, err := ioutil.ReadFile(img)
@@ -292,7 +242,6 @@ func prepareImageTest(t *testing.T) (svc *FileSystem, teardown func()) {
Location: loc,
Staging: staging,
Partitions: 100,
MaxSize: 1500,
}
teardown = func() {
+57 -10
View File
@@ -31,26 +31,35 @@ import (
// It also provides async Submit with func param retrieving all submitting ids.
// Submitted ids committed (i.e. moved from staging to final) on TTL expiration.
type Service struct {
Store
TTL time.Duration // for how long file allowed on staging
ImageAPI string // image api matching path
ServiceParams
store Store
wg sync.WaitGroup
submitCh chan submitReq
once sync.Once
term int32 // term value used atomically to detect emergency termination
}
// ServiceParams contains externally adjustable parameters of Service
type ServiceParams struct {
TTL time.Duration // for how long file allowed on staging
ImageAPI string // image api matching path
MaxSize int
MaxHeight int
MaxWidth int
}
// To regenerate mock run from this directory:
// sh -c "mockery -inpkg -name Store -print > /tmp/image-mock.tmp && mv /tmp/image-mock.tmp image_mock.go"
// Store defines interface for saving and loading pictures.
// Declares two-stage save with Commit. Save stores to staging area and Commit moves to the final location
// Declares two-stage save with Commit. Save stores to staging area and Commit moves to the final location.
// Two-stage commit scheme is used for not storing images which are uploaded but later never used in the comments,
// e.g. when somebody uploaded a picture but did not sent the comment.
type Store interface {
Save(fileName string, userID string, r io.Reader) (id string, err error) // get name and reader and returns ID of stored (staging) image
SaveWithID(id string, r io.Reader) (string, error) // store image for passed id to staging
Load(id string) ([]byte, error) // load image by ID. Caller has to close the reader.
SizeLimit() int // max image size
Save(userID string, img []byte) (id string, err error) // get name and reader and returns ID of stored (staging) image
SaveWithID(id string, img []byte) (string, error) // store image for passed id to staging
Load(id string) ([]byte, error) // load image by ID. Caller has to close the reader.
Commit(id string) error // move image from staging to permanent
Cleanup(ctx context.Context, ttl time.Duration) error // run removal loop for old images on staging
@@ -63,6 +72,10 @@ type submitReq struct {
TS time.Time
}
func NewService(s Store, p ServiceParams) *Service {
return &Service{ServiceParams: p, store: s}
}
// Submit multiple ids via function for delayed commit
func (s *Service) Submit(idsFn func() []string) {
if idsFn == nil || s == nil {
@@ -81,7 +94,7 @@ func (s *Service) Submit(idsFn func() []string) {
time.Sleep(time.Millisecond * 10) // small sleep to relive busy wait but keep reactive for term (close)
}
for _, id := range req.idsFn() {
if err := s.Commit(id); err != nil {
if err := s.store.Commit(id); err != nil {
log.Printf("[WARN] failed to commit image %s", id)
}
}
@@ -127,7 +140,7 @@ func (s *Service) Cleanup(ctx context.Context) {
log.Printf("[INFO] cleanup terminated, %v", ctx.Err())
return
case <-time.After(s.TTL / 2): // cleanup call on every 1/2 TTL
if err := s.Store.Cleanup(ctx, s.TTL); err != nil {
if err := s.store.Cleanup(ctx, s.TTL); err != nil {
log.Printf("[WARN] failed to cleanup, %v", err)
}
}
@@ -151,6 +164,40 @@ func (s *Service) Close() {
s.wg.Wait()
}
// Load wraps storage Load function.
func (s *Service) Load(id string) ([]byte, error) {
return s.store.Load(id)
}
// Save wraps storage Save function, validating and resizing the image before calling it.
func (s *Service) Save(userID string, r io.Reader) (id string, err error) {
img, err := s.prepareImage(r)
if err != nil {
return "", err
}
return s.store.Save(userID, img)
}
// SaveWithID wraps storage SaveWithID function, validating and resizing the image before calling it.
func (s *Service) SaveWithID(id string, r io.Reader) (string, error) {
img, err := s.prepareImage(r)
if err != nil {
return "", err
}
return s.store.SaveWithID(id, img)
}
// prepareImage calls readAndValidateImage and resize on provided image.
func (s *Service) prepareImage(r io.Reader) ([]byte, error) {
data, err := readAndValidateImage(r, s.MaxSize)
if err != nil {
return nil, errors.Wrapf(err, "can't load image")
}
data = resize(data, s.MaxWidth, s.MaxHeight)
return data, 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 data if resizing is not needed or failed.
+14 -29
View File
@@ -3,7 +3,6 @@
package image
import context "context"
import io "io"
import mock "github.com/stretchr/testify/mock"
import time "time"
@@ -63,20 +62,20 @@ func (_m *MockStore) Load(id string) ([]byte, error) {
return r0, r1
}
// Save provides a mock function with given fields: fileName, userID, r
func (_m *MockStore) Save(fileName string, userID string, r io.Reader) (string, error) {
ret := _m.Called(fileName, userID, r)
// Save provides a mock function with given fields: userID, img
func (_m *MockStore) Save(userID string, img []byte) (string, error) {
ret := _m.Called(userID, img)
var r0 string
if rf, ok := ret.Get(0).(func(string, string, io.Reader) string); ok {
r0 = rf(fileName, userID, r)
if rf, ok := ret.Get(0).(func(string, []byte) string); ok {
r0 = rf(userID, img)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string, io.Reader) error); ok {
r1 = rf(fileName, userID, r)
if rf, ok := ret.Get(1).(func(string, []byte) error); ok {
r1 = rf(userID, img)
} else {
r1 = ret.Error(1)
}
@@ -84,37 +83,23 @@ func (_m *MockStore) Save(fileName string, userID string, r io.Reader) (string,
return r0, r1
}
// SaveWithID provides a mock function with given fields: id, r
func (_m *MockStore) SaveWithID(id string, r io.Reader) (string, error) {
ret := _m.Called(id, r)
// SaveWithID provides a mock function with given fields: id, img
func (_m *MockStore) SaveWithID(id string, img []byte) (string, error) {
ret := _m.Called(id, img)
var r0 string
if rf, ok := ret.Get(0).(func(string, io.Reader) string); ok {
r0 = rf(id, r)
if rf, ok := ret.Get(0).(func(string, []byte) string); ok {
r0 = rf(id, img)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(string, io.Reader) error); ok {
r1 = rf(id, r)
if rf, ok := ret.Get(1).(func(string, []byte) error); ok {
r1 = rf(id, img)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SizeLimit provides a mock function with given fields:
func (_m *MockStore) SizeLimit() int {
ret := _m.Called()
var r0 int
if rf, ok := ret.Get(0).(func() int); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(int)
}
return r0
}
+69 -6
View File
@@ -4,8 +4,11 @@ import (
"bytes"
"context"
"image"
"io"
"io/ioutil"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -14,8 +17,68 @@ import (
"github.com/stretchr/testify/require"
)
func TestService_SaveAndLoad(t *testing.T) {
store := MockStore{}
svc := NewService(&store, ServiceParams{MaxSize: 1500, MaxWidth: 32, MaxHeight: 32})
store.On("Save", "user1", mock.Anything).Return("user1/test_id", nil)
id, err := svc.Save("user1", gopherPNG())
assert.NoError(t, err)
assert.Equal(t, "user1/test_id", id)
store.On("SaveWithID", "test_id", mock.Anything).Return("test_id", nil)
id, err = svc.SaveWithID("test_id", gopherPNG())
assert.NoError(t, err)
assert.Equal(t, "test_id", id)
store.On("Load", "test_id", mock.Anything).Return(nil, nil)
img, err := svc.Load("test_id")
assert.NoError(t, err)
assert.Nil(t, img)
}
func TestService_Resize(t *testing.T) {
img, err := readAndValidateImage(gopherPNG(), 1500)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
img = resize(img, 32, 32)
assert.Equal(t, 1135, len(img))
}
func TestService_ResizeJpeg(t *testing.T) {
fh, err := os.Open("testdata/circles.jpg")
defer func() { assert.NoError(t, fh.Close()) }()
assert.NoError(t, err)
img, err := readAndValidateImage(fh, 32000)
assert.NoError(t, err)
assert.Equal(t, 23983, len(img))
img = resize(img, 400, 300)
assert.Equal(t, 10918, len(img))
}
func TestService_SaveTooLarge(t *testing.T) {
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/"}}
svc.MaxSize = 2000
_, err := svc.Save("user2", io.MultiReader(gopherPNG(), gopherPNG()))
assert.Error(t, err)
assert.Contains(t, err.Error(), "is too large")
_, err = svc.SaveWithID("test_id", io.MultiReader(gopherPNG(), gopherPNG()))
assert.Error(t, err)
assert.Contains(t, err.Error(), "is too large")
}
func TestService_WrongFormat(t *testing.T) {
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/"}}
_, err := svc.Save("user1", strings.NewReader("blah blah bad image"))
assert.Error(t, err)
}
func TestService_ExtractPictures(t *testing.T) {
svc := Service{ImageAPI: "/blah/"}
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/"}}
html := `blah <img src="/blah/user1/pic1.png"/> foo
<img src="/blah/user2/pic3.png"/> xyz <p>123</p> <img src="/pic3.png"/> <img src="https://i.ibb.co/0cqqqnD/ezgif-5-3b07b6b97610.png" alt="">`
ids, err := svc.ExtractPictures(html)
@@ -26,7 +89,7 @@ func TestService_ExtractPictures(t *testing.T) {
}
func TestService_ExtractPictures2(t *testing.T) {
svc := Service{ImageAPI: "https://remark42.radio-t.com/api/v1/picture/"}
svc := Service{ServiceParams: ServiceParams{ImageAPI: "https://remark42.radio-t.com/api/v1/picture/"}}
html := "<p>TLDR: такое в go пока правильно посчитать трудно. То, что они считают это общее количество go packages в коде." +
"</p>\n\n<p>Пакеты в го это средство организации кода, они могут быть связанны друг с другом в рамках одной библиотеки (модуля). Например одна из моих вот так выглядит на libraries.io:</p>\n\n<p><img src=\"https://remark42.radio-t.com/api/v1/picture/github_ef0f706a79cc24b17bbbb374cd234a691d034128/bjttt8ahajfmrhsula10.png\" alt=\"bjtr0-201906-08110846-i324c.png\"/></p>\n\n<p>По форме все верно, это все packages, но по сути это все одна библиотека организованная таким образом. При ее импорте, например посредством go mod, она выглядит как один модуль, т.е. <code>github.com/go-pkgz/auth v0.5.2</code>.</p>\n"
ids, err := svc.ExtractPictures(html)
@@ -39,7 +102,7 @@ func TestService_Cleanup(t *testing.T) {
store := MockStore{}
store.On("Cleanup", mock.Anything, mock.Anything).Times(10).Return(nil)
svc := Service{Store: &store, TTL: 100 * time.Millisecond}
svc := Service{store: &store, ServiceParams: ServiceParams{TTL: 100 * time.Millisecond}}
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*549)
defer cancel()
svc.Cleanup(ctx)
@@ -49,7 +112,7 @@ func TestService_Cleanup(t *testing.T) {
func TestService_Submit(t *testing.T) {
store := MockStore{}
store.On("Commit", mock.Anything, mock.Anything).Times(5).Return(nil)
svc := Service{Store: &store, ImageAPI: "/blah/", TTL: time.Millisecond * 100}
svc := Service{store: &store, ServiceParams: ServiceParams{ImageAPI: "/blah/", TTL: time.Millisecond * 100}}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
svc.Submit(func() []string { return []string{"id4", "id5"} })
svc.Submit(nil)
@@ -61,7 +124,7 @@ func TestService_Submit(t *testing.T) {
func TestService_Close(t *testing.T) {
store := MockStore{}
store.On("Commit", mock.Anything, mock.Anything).Times(5).Return(nil)
svc := Service{Store: &store, ImageAPI: "/blah/", TTL: time.Millisecond * 500}
svc := Service{store: &store, ServiceParams: ServiceParams{ImageAPI: "/blah/", TTL: time.Millisecond * 500}}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
svc.Submit(func() []string { return []string{"id4", "id5"} })
svc.Submit(nil)
@@ -72,7 +135,7 @@ func TestService_Close(t *testing.T) {
func TestService_SubmitDelay(t *testing.T) {
store := MockStore{}
store.On("Commit", mock.Anything, mock.Anything).Times(5).Return(nil)
svc := Service{Store: &store, ImageAPI: "/blah/", TTL: time.Millisecond * 100}
svc := Service{store: &store, ServiceParams: ServiceParams{ImageAPI: "/blah/", TTL: time.Millisecond * 100}}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
time.Sleep(150 * time.Millisecond) // let first batch to pass TTL
svc.Submit(func() []string { return []string{"id4", "id5"} })
+58
View File
@@ -0,0 +1,58 @@
package image
import (
"context"
"encoding/base64"
"encoding/json"
"io/ioutil"
"strings"
"time"
"github.com/go-pkgz/jrpc"
)
// RPC implements remote engine and delegates all Calls to remote http server
type RPC struct {
jrpc.Client
}
func (r *RPC) Save(userID string, img []byte) (id string, err error) {
resp, err := r.Call("image.save", userID, img)
if err != nil {
return "", err
}
err = json.Unmarshal(*resp.Result, &id)
return id, err
}
func (r *RPC) SaveWithID(id string, img []byte) (string, error) {
resp, err := r.Call("image.save_with_id", id, img)
if err != nil {
return "", err
}
var newID string
err = json.Unmarshal(*resp.Result, &newID)
return newID, err
}
func (r *RPC) Load(id string) ([]byte, error) {
resp, err := r.Call("image.load", id)
if err != nil {
return nil, err
}
var rawImg string
if err = json.Unmarshal(*resp.Result, &rawImg); err != nil {
return nil, err
}
return ioutil.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawImg)))
}
func (r *RPC) Commit(id string) error {
_, err := r.Call("image.commit", id)
return err
}
func (r *RPC) Cleanup(_ context.Context, ttl time.Duration) error {
_, err := r.Call("image.cleanup", ttl)
return err
}
@@ -0,0 +1,90 @@
package image
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-pkgz/jrpc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRemote_Save(t *testing.T) {
ts := testServer(t, fmt.Sprintf(`{"method":"image.save","params":["admin","%s"],"id":1}`, gopher),
`{"result":"12345","id":1}`)
defer ts.Close()
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
var a Store = &c
_ = a
res, err := c.Save("admin", gopherPNGBytes())
assert.NoError(t, err)
assert.Equal(t, "12345", res)
}
func TestRemote_SaveWithID(t *testing.T) {
ts := testServer(t, fmt.Sprintf(`{"method":"image.save_with_id","params":["54321","%s"],"id":1}`, gopher),
`{"result":"12345","id":1}`)
defer ts.Close()
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
var a Store = &c
_ = a
res, err := c.SaveWithID("54321", gopherPNGBytes())
assert.NoError(t, err)
assert.Equal(t, "12345", res)
}
func TestRemote_Load(t *testing.T) {
ts := testServer(t, `{"method":"image.load","params":"54321","id":1}`,
fmt.Sprintf(`{"result":"%v","id":1}`, gopher))
defer ts.Close()
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
var a Store = &c
_ = a
res, err := c.Load("54321")
assert.NoError(t, err)
assert.Equal(t, gopherPNGBytes(), res)
}
func TestRemote_Commit(t *testing.T) {
ts := testServer(t, `{"method":"image.commit","params":"gopher_id","id":1}`, `{"id":1}`)
defer ts.Close()
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
var a Store = &c
_ = a
err := c.Commit("gopher_id")
assert.NoError(t, err)
}
func TestRemote_Cleanup(t *testing.T) {
ts := testServer(t, `{"method":"image.cleanup","params":60000000000,"id":1}`, `{"id":1}`)
defer ts.Close()
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
var a Store = &c
_ = a
err := c.Cleanup(context.TODO(), time.Minute)
assert.NoError(t, err)
}
func testServer(t *testing.T, req, resp string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, req, string(body))
_, _ = fmt.Fprint(w, resp)
}))
}
+1 -1
View File
@@ -1278,7 +1278,7 @@ func TestService_submitImages(t *testing.T) {
mockStore := image.MockStore{}
mockStore.On("Commit", mock.Anything, mock.Anything).Times(2).Return(nil)
imgSvc := &image.Service{Store: &mockStore, TTL: time.Millisecond * 50}
imgSvc := image.NewService(&mockStore, image.ServiceParams{TTL: 50 * time.Millisecond * 50})
// two comments for https://radio-t.com
eng, teardown := prepStoreEngine(t)
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/umputun/remark/backend
go 1.13
go 1.14
require (
cloud.google.com/go v0.47.0 // indirect
+76
View File
@@ -1,10 +1,16 @@
# cloud.google.com/go v0.47.0
## explicit
cloud.google.com/go/compute/metadata
# github.com/Depado/bfchroma v1.2.0
## explicit
github.com/Depado/bfchroma
# github.com/PuerkitoBio/goquery v1.5.0
## explicit
github.com/PuerkitoBio/goquery
# github.com/ajg/form v1.5.1
## explicit
# github.com/alecthomas/chroma v0.6.0
## explicit
github.com/alecthomas/chroma
github.com/alecthomas/chroma/formatters/html
github.com/alecthomas/chroma/lexers
@@ -36,8 +42,10 @@ github.com/alecthomas/chroma/lexers/x
github.com/alecthomas/chroma/lexers/y
github.com/alecthomas/chroma/styles
# github.com/andybalholm/cascadia v1.1.0
## explicit
github.com/andybalholm/cascadia
# github.com/coreos/bbolt v1.3.3
## explicit
github.com/coreos/bbolt
# github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964
github.com/danwakefield/fnmatch
@@ -47,25 +55,36 @@ github.com/davecgh/go-spew/spew
github.com/dghubble/oauth1
github.com/dghubble/oauth1/twitter
# github.com/dgrijalva/jwt-go v3.2.0+incompatible
## explicit
github.com/dgrijalva/jwt-go
# github.com/didip/tollbooth v4.0.2+incompatible
## explicit
github.com/didip/tollbooth
github.com/didip/tollbooth/errors
github.com/didip/tollbooth/libstring
github.com/didip/tollbooth/limiter
# github.com/didip/tollbooth_chi v0.0.0-20170928041846-6ab5f3083f3d
## explicit
github.com/didip/tollbooth_chi
# github.com/dlclark/regexp2 v1.1.6
github.com/dlclark/regexp2
github.com/dlclark/regexp2/syntax
# github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072
## explicit
# github.com/gavv/httpexpect v2.0.0+incompatible
## explicit
# github.com/go-chi/chi v4.0.2+incompatible
## explicit
github.com/go-chi/chi
github.com/go-chi/chi/middleware
# github.com/go-chi/cors v1.0.0
## explicit
github.com/go-chi/cors
# github.com/go-chi/render v1.0.1
## explicit
github.com/go-chi/render
# github.com/go-pkgz/auth v0.9.0
## explicit
github.com/go-pkgz/auth
github.com/go-pkgz/auth/avatar
github.com/go-pkgz/auth/logger
@@ -74,18 +93,24 @@ github.com/go-pkgz/auth/provider
github.com/go-pkgz/auth/provider/sender
github.com/go-pkgz/auth/token
# github.com/go-pkgz/jrpc v0.1.0
## explicit
github.com/go-pkgz/jrpc
# github.com/go-pkgz/lcw v0.5.0
## explicit
github.com/go-pkgz/lcw
# github.com/go-pkgz/lgr v0.6.3
## explicit
github.com/go-pkgz/lgr
# github.com/go-pkgz/repeater v1.1.3
## explicit
github.com/go-pkgz/repeater
github.com/go-pkgz/repeater/strategy
# github.com/go-pkgz/rest v1.4.1
## explicit
github.com/go-pkgz/rest
github.com/go-pkgz/rest/logger
# github.com/go-pkgz/syncs v1.1.1
## explicit
github.com/go-pkgz/syncs
# github.com/go-redis/redis/v7 v7.0.0-beta.4
github.com/go-redis/redis/v7
@@ -102,49 +127,91 @@ github.com/golang/protobuf/proto
# github.com/golang/snappy v0.0.1
github.com/golang/snappy
# github.com/google/uuid v1.1.1
## explicit
github.com/google/uuid
# github.com/gopherjs/gopherjs v0.0.0-20190812055157-5d271430af9f
## explicit
# github.com/gorilla/feeds v1.1.1
## explicit
github.com/gorilla/feeds
# github.com/gorilla/websocket v1.4.0
## explicit
# github.com/hashicorp/errwrap v1.0.0
github.com/hashicorp/errwrap
# github.com/hashicorp/go-multierror v1.0.0
## explicit
github.com/hashicorp/go-multierror
# github.com/hashicorp/golang-lru v0.5.3
github.com/hashicorp/golang-lru
github.com/hashicorp/golang-lru/simplelru
# github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc
## explicit
github.com/jessevdk/go-flags
# github.com/klauspost/compress v1.7.6
## explicit
# github.com/klauspost/cpuid v1.2.1
## explicit
# github.com/kyokomi/emoji v2.1.0+incompatible
## explicit
github.com/kyokomi/emoji
# github.com/mattn/go-colorable v0.1.2
## explicit
# github.com/mattn/go-isatty v0.0.9
## explicit
# github.com/microcosm-cc/bluemonday v1.0.2
## explicit
github.com/microcosm-cc/bluemonday
# github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022
github.com/nullrocks/identicon
# github.com/onsi/ginkgo v1.9.0
## explicit
# github.com/onsi/gomega v1.6.0
## explicit
# github.com/patrickmn/go-cache v2.1.0+incompatible
## explicit
github.com/patrickmn/go-cache
# github.com/pkg/errors v0.8.1
## explicit
github.com/pkg/errors
# github.com/pmezard/go-difflib v1.0.0
github.com/pmezard/go-difflib/difflib
# github.com/rakyll/statik v0.1.6
## explicit
github.com/rakyll/statik/fs
# github.com/rs/xid v1.2.1
## explicit
github.com/rs/xid
# github.com/russross/blackfriday/v2 v2.0.1
## explicit
github.com/russross/blackfriday/v2
# github.com/shurcooL/sanitized_anchor_name v1.0.0
github.com/shurcooL/sanitized_anchor_name
# github.com/smartystreets/assertions v1.0.1
## explicit
# github.com/stretchr/objx v0.2.0
## explicit
github.com/stretchr/objx
# github.com/stretchr/testify v1.4.0
## explicit
github.com/stretchr/testify/assert
github.com/stretchr/testify/mock
github.com/stretchr/testify/require
# github.com/tidwall/buntdb v1.1.0
## explicit
# github.com/tidwall/gjson v1.3.2
## explicit
# github.com/valyala/fasthttp v1.4.0
## explicit
# github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c
github.com/xdg/scram
# github.com/xdg/stringprep v1.0.0
github.com/xdg/stringprep
# github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b
## explicit
# github.com/xeipuuv/gojsonschema v1.1.0
## explicit
# go.mongodb.org/mongo-driver v1.1.2
## explicit
go.mongodb.org/mongo-driver/bson
go.mongodb.org/mongo-driver/bson/bsoncodec
go.mongodb.org/mongo-driver/bson/bsonrw
@@ -175,13 +242,16 @@ go.mongodb.org/mongo-driver/x/mongo/driver/topology
go.mongodb.org/mongo-driver/x/mongo/driver/uuid
go.mongodb.org/mongo-driver/x/mongo/driver/wiremessage
# golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
## explicit
golang.org/x/crypto/acme
golang.org/x/crypto/acme/autocert
golang.org/x/crypto/pbkdf2
# golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
## explicit
golang.org/x/image/draw
golang.org/x/image/math/f64
# golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a
## explicit
golang.org/x/net/context
golang.org/x/net/context/ctxhttp
golang.org/x/net/html
@@ -197,8 +267,10 @@ golang.org/x/oauth2/jws
golang.org/x/oauth2/jwt
golang.org/x/oauth2/yandex
# golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
## explicit
golang.org/x/sync/semaphore
# golang.org/x/sys v0.0.0-20191026070338-33540a1f6037
## explicit
golang.org/x/sys/unix
# golang.org/x/text v0.3.2
golang.org/x/text/secure/bidirule
@@ -206,8 +278,10 @@ golang.org/x/text/transform
golang.org/x/text/unicode/bidi
golang.org/x/text/unicode/norm
# golang.org/x/time v0.0.0-20191024005414-555d28b269f0
## explicit
golang.org/x/time/rate
# google.golang.org/appengine v1.6.5
## explicit
google.golang.org/appengine
google.golang.org/appengine/internal
google.golang.org/appengine/internal/app_identity
@@ -219,8 +293,10 @@ google.golang.org/appengine/internal/remote_api
google.golang.org/appengine/internal/urlfetch
google.golang.org/appengine/urlfetch
# gopkg.in/oauth2.v3 v3.11.0
## explicit
gopkg.in/oauth2.v3
gopkg.in/oauth2.v3/errors
gopkg.in/oauth2.v3/server
# gopkg.in/yaml.v2 v2.2.4
## explicit
gopkg.in/yaml.v2
+95
View File
@@ -0,0 +1,95 @@
import { setJsonItem, getJsonItem, updateJsonItem } from './local-storage';
const LS_KEY = 'test';
describe('getJsonItem', () => {
afterAll(() => {
localStorage.clear();
});
it('should set json to empty localStorage', () => {
setJsonItem<Record<string, string>>(LS_KEY, {});
expect(localStorage.getItem(LS_KEY)).toBe('{}');
});
it('should update json in localStoeage', () => {
setJsonItem<any[]>(LS_KEY, []);
expect(localStorage.getItem(LS_KEY)).toBe('[]');
});
});
describe('setJsonItem', () => {
let consoleSpy: jest.SpyInstance;
beforeEach(() => {
consoleSpy = jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
localStorage.clear();
});
it('should return null when localStorage is empty', () => {
expect(getJsonItem(LS_KEY)).toBe(null);
});
it('should return value of key', () => {
localStorage.setItem(LS_KEY, JSON.stringify({}));
expect(getJsonItem(LS_KEY)).toEqual({});
localStorage.setItem(LS_KEY, JSON.stringify([]));
expect(getJsonItem(LS_KEY)).toEqual([]);
localStorage.setItem(LS_KEY, JSON.stringify(null));
expect(getJsonItem(LS_KEY)).toBe(null);
localStorage.setItem(LS_KEY, JSON.stringify(1));
expect(getJsonItem(LS_KEY)).toBe(1);
localStorage.setItem(LS_KEY, JSON.stringify(1));
expect(getJsonItem(LS_KEY)).toBe(1);
});
it('should return `null` if value in localStorage is not JSON', () => {
localStorage.setItem(LS_KEY, '"{:"""');
expect(getJsonItem(LS_KEY)).toBe(null);
expect(consoleSpy).toHaveBeenCalled();
localStorage.setItem(LS_KEY, 'asdas');
expect(getJsonItem(LS_KEY)).toBe(null);
expect(consoleSpy).toHaveBeenCalled();
});
});
describe('updateJsonItem', () => {
afterEach(() => {
localStorage.clear();
});
it('should set data to empty localStorage', () => {
updateJsonItem<Record<string, string>>(LS_KEY, {});
expect(localStorage.getItem(LS_KEY)).toBe(JSON.stringify({}));
});
it('should update object in localStorage', () => {
localStorage.setItem(LS_KEY, JSON.stringify({ x: 1 }));
updateJsonItem(LS_KEY, { y: 1 });
expect(localStorage.getItem(LS_KEY)).toBe(JSON.stringify({ x: 1, y: 1 }));
});
it('should update array in localStorage', () => {
localStorage.setItem(LS_KEY, JSON.stringify([1, 2, 3]));
updateJsonItem(LS_KEY, [4, 5, 6]);
expect(localStorage.getItem(LS_KEY)).toBe(JSON.stringify([1, 2, 3, 4, 5, 6]));
});
it('should update data in localStorage with merge', () => {
localStorage.setItem(LS_KEY, JSON.stringify([3, 4, 5]));
updateJsonItem<any[]>(LS_KEY, data => [1, 2, ...data]);
expect(localStorage.getItem(LS_KEY)).toBe(JSON.stringify([1, 2, 3, 4, 5]));
});
});
+49
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { IS_STORAGE_AVAILABLE } from './constants';
const failMessage = 'remark42: localStorage access denied, check browser preferences';
@@ -20,3 +21,51 @@ export const removeItem = IS_STORAGE_AVAILABLE
: () => {
console.error(failMessage); // eslint-disable-line no-console
};
export function getJsonItem<T = any>(key: string): T | null {
try {
const json = getItem(key);
if (json === null) {
return null;
}
const data = JSON.parse(json);
return data;
} catch (e) {
console.error(`remark42: error on read JSON from ${key} in localStorage`, e); // eslint-disable-line no-console
return null;
}
}
export function setJsonItem<T = any>(key: string, data: T) {
try {
setItem(key, JSON.stringify(data));
} catch (e) {
console.error(`remark42: error on parse JSON from ${key} in localStorage`, e); // eslint-disable-line no-console
}
}
export function updateJsonItem<T = Record<string, any> | any[]>(key: string, value: (data: T) => T): void;
export function updateJsonItem<T = any[]>(key: string, value: T): void;
export function updateJsonItem<T = Record<string, any>>(key: string, value: T) {
const savedData = getJsonItem<any>(key);
if (Array.isArray(value)) {
setJsonItem(key, [...savedData, ...value]);
return;
}
if (value !== null && typeof value === 'object') {
setJsonItem(key, { ...savedData, ...value });
return;
}
if (typeof value === 'function') {
setJsonItem(key, value(savedData));
return;
}
throw new Error(`remark42: error on update JSON for ${key} in localStorage`);
}
@@ -4,9 +4,20 @@ import { shallow } from 'enzyme';
import { user } from '@app/testUtils/mocks/user';
import { StaticStore } from '@app/common/static_store';
import { LS_SAVED_COMMENT_VALUE } from '@app/common/constants';
import * as localStorageModule from '@app/common/local-storage';
import { CommentForm, Props } from './comment-form';
import { SubscribeByEmail } from './__subscribe-by-email';
import TextareaAutosize from './textarea-autosize';
function createEvent<T = any>(type: string, value: T) {
const event = new Event(type);
Object.defineProperty(event, 'target', { value });
return event;
}
const DEFAULT_PROPS: Readonly<Omit<Props, 'intl'>> = {
mode: 'main',
@@ -50,4 +61,74 @@ describe('<CommentForm />', () => {
expect(wrapper.exists(SubscribeByEmail)).toEqual(false);
});
describe('initial value of comment', () => {
afterEach(() => {
localStorage.clear();
});
it('should has empty value', () => {
localStorage.setItem(LS_SAVED_COMMENT_VALUE, JSON.stringify({ 2: 'text' }));
const props = { ...DEFAULT_PROPS, user, intl };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.state('text')).toBe('');
expect(wrapper.find(TextareaAutosize).prop('value')).toBe('');
});
it('should get initial value from localStorage', () => {
const COMMENT_VALUE = 'text';
localStorage.setItem(LS_SAVED_COMMENT_VALUE, JSON.stringify({ 1: COMMENT_VALUE }));
const props = { ...DEFAULT_PROPS, user, intl };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.state('text')).toBe(COMMENT_VALUE);
expect(wrapper.find(TextareaAutosize).prop('value')).toBe(COMMENT_VALUE);
});
it('should get initial value from props instead localStorage', () => {
const COMMENT_VALUE = 'text from props';
localStorage.setItem(LS_SAVED_COMMENT_VALUE, JSON.stringify({ 1: 'text from localStorage' }));
const props = { ...DEFAULT_PROPS, user, intl, value: COMMENT_VALUE };
const wrapper = shallow(<CommentForm {...props} />);
expect(wrapper.state('text')).toBe(COMMENT_VALUE);
expect(wrapper.find(TextareaAutosize).prop('value')).toBe(COMMENT_VALUE);
});
});
describe('update value of comment in localStorage', () => {
afterEach(() => {
localStorage.clear();
});
it('should update value', () => {
const props = { ...DEFAULT_PROPS, user, intl };
const wrapper = shallow(<CommentForm {...props} />);
// @ts-ignore
const instance: CommentForm = wrapper.instance();
instance.onInput(createEvent('input', { value: '1' }));
expect(localStorage.getItem(LS_SAVED_COMMENT_VALUE)).toBe('{"1":"1"}');
instance.onInput(createEvent('input', { value: '11' }));
expect(localStorage.getItem(LS_SAVED_COMMENT_VALUE)).toBe('{"1":"11"}');
});
it('should clear value after send', async () => {
localStorage.setItem(LS_SAVED_COMMENT_VALUE, JSON.stringify({ '1': 'asd' }));
const updateJsonItemSpy = jest.spyOn(localStorageModule, 'updateJsonItem');
const props = { ...DEFAULT_PROPS, user, intl };
const wrapper = shallow(<CommentForm {...props} />);
// @ts-ignore
const instance: CommentForm = wrapper.instance();
await instance.send(createEvent('send', { preventDefault: () => undefined }));
expect(updateJsonItemSpy).toHaveBeenCalled();
expect(localStorage.getItem(LS_SAVED_COMMENT_VALUE)).toBe(JSON.stringify({}));
});
});
});
@@ -11,7 +11,7 @@ import { sleep } from '@app/utils/sleep';
import { replaceSelection } from '@app/utils/replaceSelection';
import { Button } from '@app/components/button';
import Auth from '@app/components/auth';
import { getItem, setItem } from '@app/common/local-storage';
import { getJsonItem, updateJsonItem } from '@app/common/local-storage';
import { LS_SAVED_COMMENT_VALUE } from '@app/common/constants';
import { SubscribeByEmail } from './__subscribe-by-email';
@@ -42,7 +42,7 @@ export interface Props {
intl: IntlShape;
}
interface State {
export interface State {
preview: string | null;
isErrorShown: boolean;
/** error message, if contains newlines, it will be splitted to multiple errors */
@@ -104,13 +104,16 @@ export class CommentForm extends Component<Props, State> {
textareaId = textareaId + 1;
this.textareaId = `textarea_${textareaId}`;
const savedCommentsJSON = getItem(LS_SAVED_COMMENT_VALUE);
let savedValue = '';
try {
if (typeof savedCommentsJSON === 'string') {
savedValue = JSON.parse(savedCommentsJSON)[this.props.id] || '';
}
} catch (e) {}
const savedComments = getJsonItem(LS_SAVED_COMMENT_VALUE);
let text = '';
if (savedComments !== null && savedComments[props.id]) {
text = savedComments[props.id];
}
if (props.value) {
text = props.value;
}
this.state = {
preview: null,
@@ -119,13 +122,11 @@ export class CommentForm extends Component<Props, State> {
errorLock: false,
isDisabled: false,
maxLength: StaticStore.config.max_comment_size,
text: props.value || savedValue,
text,
buttonText: null,
};
this.send = this.send.bind(this);
this.getPreview = this.getPreview.bind(this);
this.onInput = this.onInput.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.onDrop = this.onDrop.bind(this);
@@ -169,12 +170,10 @@ export class CommentForm extends Component<Props, State> {
}
}
onInput(e: Event) {
onInput = (e: Event) => {
const { value } = e.target as HTMLInputElement;
try {
setItem(LS_SAVED_COMMENT_VALUE, JSON.stringify({ [this.props.id]: value }));
} catch (e) {}
updateJsonItem(LS_SAVED_COMMENT_VALUE, { [this.props.id]: value });
if (this.state.errorLock) {
this.setState({
@@ -183,13 +182,14 @@ export class CommentForm extends Component<Props, State> {
});
return;
}
this.setState({
isErrorShown: false,
errorMessage: null,
preview: null,
text: value,
});
}
};
async onPaste(e: ClipboardEvent) {
if (!(e.clipboardData && e.clipboardData.files.length > 0)) {
@@ -200,32 +200,35 @@ export class CommentForm extends Component<Props, State> {
await this.uploadImages(files);
}
send(e: Event) {
const text = this.textAreaRef.current ? this.textAreaRef.current.getValue() : this.state.text;
const props = this.props;
send = async (e: Event) => {
const { text } = this.state;
if (e) e.preventDefault();
if (!text || !text.trim()) return;
if (text === this.props.value) {
this.props.onCancel && this.props.onCancel();
this.setState({ preview: null, text: '' });
}
this.setState({ isDisabled: true, isErrorShown: false, text });
try {
await this.props.onSubmit(text, pageTitle || document.title);
updateJsonItem<Record<string, string>>(LS_SAVED_COMMENT_VALUE, data => {
delete data[this.props.id];
props
.onSubmit(text, pageTitle || document.title)
.then(() => {
this.setState({ preview: null, text: '' });
})
.catch(e => {
const errorMessage = extractErrorMessageFromResponse(e, this.props.intl);
this.setState({ isErrorShown: true, errorMessage });
})
.finally(() => this.setState({ isDisabled: false }));
}
return data;
});
this.setState({ preview: null, text: '' });
} catch (e) {
this.setState({
isErrorShown: true,
errorMessage: extractErrorMessageFromResponse(e, this.props.intl),
});
}
this.setState({ isDisabled: false });
};
getPreview() {
const text = this.textAreaRef.current ? this.textAreaRef.current.getValue() : this.state.text;
@@ -835,7 +835,6 @@ class Comment extends Component<Props, State> {
intl={this.props.intl}
user={props.user}
theme={props.theme}
value=""
mode="reply"
mix="comment__input"
onSubmit={(text, title) => this.addComment(text, title, o.id)}