diff --git a/.gitignore b/.gitignore index 0f74b453..ec8cdf79 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ debug.test .mongo remark42 /bin/ -/backend/var/ \ No newline at end of file +/backend/var/ diff --git a/Dockerfile b/Dockerfile index 05e7eff2..a52f40c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,9 @@ WORKDIR /build/backend RUN \ if [ -f .mongo ] ; then export MONGO_TEST=$(cat .mongo) ; fi && \ cd app && \ - if [ -z "$SKIP_BACKEND_TEST" ] ; then go test -mod=vendor -covermode=count -coverprofile=/profile.cov ./... ; \ + if [ -z "$SKIP_BACKEND_TEST" ] ; then \ + go test -mod=vendor -covermode=count -coverprofile=/profile.cov_tmp ./... && \ + cat /profile.cov_tmp | grep -v "_mock.go" > /profile.cov ; \ else echo "skip backend test" ; fi RUN echo "mongo=${MONGO_TEST}" >> /etc/hosts @@ -37,10 +39,10 @@ RUN echo "mongo=${MONGO_TEST}" >> /etc/hosts # linters RUN if [ -z "$SKIP_BACKEND_TEST" ] ; then \ if [ -f .mongo ] ; then export MONGO_TEST=$(cat .mongo) ; fi && \ - golangci-lint run --out-format=tab --disable-all --tests=false --enable=unconvert \ - --enable=megacheck --enable=structcheck --enable=gas --enable=gocyclo --enable=dupl --enable=misspell \ - --enable=unparam --enable=varcheck --enable=deadcode --enable=typecheck \ - --enable=ineffassign --enable=varcheck ./... ; \ + golangci-lint run --out-format=tab --disable-all --tests=false --enable=unconvert \ + --enable=megacheck --enable=structcheck --enable=gas --enable=gocyclo --enable=dupl --enable=misspell \ + --enable=unparam --enable=varcheck --enable=deadcode --enable=typecheck \ + --enable=ineffassign --enable=varcheck ./... ; \ else echo "skip backend linters" ; fi # submit coverage to coverals if COVERALLS_TOKEN in env @@ -50,11 +52,10 @@ RUN if [ -z "$COVERALLS_TOKEN" ] ; then \ # if DRONE presented use DRONE_* git env to make version RUN \ - if [ -z "$DRONE" ] ; then \ - echo "runs outside of drone" && version="local"; \ + if [ -z "$DRONE" ] ; then echo "runs outside of drone" && version="local"; \ else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S); fi && \ echo "version=$version" && \ - go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app + go build -mod=vendor -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app FROM node:10.11-alpine as build-frontend-deps diff --git a/README.md b/README.md index 82e3f92d..4055096b 100644 --- a/README.md +++ b/README.md @@ -86,63 +86,68 @@ _this is the recommended way to run remark42_ #### Parameters -| Command line | Environment | Default | Description | -| ----------------------- | ----------------------- | --------------------- | ------------------------------------------------ | -| url | REMARK_URL | | url to remark42 server, _required_ | -| secret | SECRET | | secret key, _required_ | -| site | SITE | `remark` | site name(s), _multi_ | -| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `mongo` | -| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory | -| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout | -| mongo.url | MONGO_URL | | mongo url for all stores using mongodb | -| mongo.db | MONGO_DB | | mongo database | -| admin.shared.id | ADMIN_SHARED_ID | | admin names (list of user ids), _multi_ | -| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin email | -| backup | BACKUP_PATH | `./var/backup` | backups location | -| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep | -| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited | -| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited | -| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited | -| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, 'bolt`, or `mongo` | -| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store | -| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store | -| avatar.rsz-lmt | AVATAR_RSZ_LMT | `0` (disabled) | max image size for resizing avatars on save | -| auth.ttl.jwt | AUTH_TTL_JWT | `5m` | jwt TTL | -| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | cookie TTL | -| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID | -| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret | -| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID | -| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret | -| auth.github.cid | AUTH_GITHUB_CID | | Github OAuth client ID | -| auth.github.csec | AUTH_GITHUB_CSEC | | Github OAuth client secret | -| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID | -| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret | -| auth.dev | AUTH_DEV | `false` | local oauth2 server, development mode only | -| auth.anon | AUTH_ANON | `false` | enable anonymous login | -| notify.type | NOTIFY_TYPE | none | type of notification (none or telegram) | -| notify.queue | NOTIFY_QUEUE | `100` | size of notification queue | -| notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token | -| notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel | -| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout | -| ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le | -| ssl.port | SSL_PORT | `8443` | port for https server | -| ssl.cert | SSL_CERT | | path to cert.pem file | -| ssl.key | SSL_KEY | | path to key.pem file | -| ssl.acme-location | SSL_ACME_LOCATION | `./var/acme` | dir where obtained le-certs will be stored | -| ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE | -| max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit | -| max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited | -| low-score | LOW_SCORE | `-5` | low score threshold | -| positive-score | POSITIVE_SCORE | `false` | enable positive score only | -| critical-score | CRITICAL_SCORE | `-10` | critical score threshold | -| positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive | -| restricted-words | RESTRICTED_WORDS | | words banned in comments (can use `*`), _multi_ | -| edit-time | EDIT_TIME | `5m` | edit window | -| read-age | READONLY_AGE | | read-only age of comments, days | -| img-proxy | IMG_PROXY | `false` | enable http->https proxy for images | -| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit | -| admin-passwd | ADMIN_PASSWD | none (disabled) | password for `admin` basic auth | -| dbg | DEBUG | `false` | debug mode | +| Command line | Environment | Default | Description | +| ----------------------- | ----------------------- | ------------------------ | ------------------------------------------------ | +| url | REMARK_URL | | url to remark42 server, _required_ | +| secret | SECRET | | secret key, _required_ | +| site | SITE | `remark` | site name(s), _multi_ | +| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `mongo` | +| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory | +| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout | +| mongo.url | MONGO_URL | | mongo url for all stores using mongodb | +| mongo.db | MONGO_DB | | mongo database | +| admin.shared.id | ADMIN_SHARED_ID | | admin names (list of user ids), _multi_ | +| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin email | +| backup | BACKUP_PATH | `./var/backup` | backups location | +| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep | +| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited | +| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited | +| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited | +| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, `bolt`, or `mongo` | +| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store | +| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store | +| avatar.rsz-lmt | AVATAR_RSZ_LMT | `0` (disabled) | max image size for resizing avatars on save | +| image.type | IMAGE_TYPE | `fs` | type of image storage, `fs`, 'bolt`, or `mongo` | +| image.max-size | IMAGE_MAX_SIZE | `5000000` | max size of image file | +| image.fs.path | IMAGE_FS_PATH | `./var/pictures` | permanent location of images | +| image.fs.staging | IMAGE_FS_STAGING | `./var/pictures.staging` | staging location of images | +| image.fs.partitions | IMAGE_FS_PARTITIONS | `100` | number of image partitions | +| auth.ttl.jwt | AUTH_TTL_JWT | `5m` | jwt TTL | +| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | cookie TTL | +| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID | +| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret | +| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID | +| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret | +| auth.github.cid | AUTH_GITHUB_CID | | Github OAuth client ID | +| auth.github.csec | AUTH_GITHUB_CSEC | | Github OAuth client secret | +| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID | +| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret | +| auth.dev | AUTH_DEV | `false` | local oauth2 server, development mode only | +| auth.anon | AUTH_ANON | `false` | enable anonymous login | +| notify.type | NOTIFY_TYPE | none | type of notification (none or telegram) | +| notify.queue | NOTIFY_QUEUE | `100` | size of notification queue | +| notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token | +| notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel | +| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout | +| ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le | +| ssl.port | SSL_PORT | `8443` | port for https server | +| ssl.cert | SSL_CERT | | path to cert.pem file | +| ssl.key | SSL_KEY | | path to key.pem file | +| ssl.acme-location | SSL_ACME_LOCATION | `./var/acme` | dir where obtained le-certs will be stored | +| ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE | +| max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit | +| max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited | +| low-score | LOW_SCORE | `-5` | low score threshold | +| positive-score | POSITIVE_SCORE | `false` | enable positive score only | +| critical-score | CRITICAL_SCORE | `-10` | critical score threshold | +| positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive | +| restricted-words | RESTRICTED_WORDS | | words banned in comments (can use `*`), _multi_ | +| edit-time | EDIT_TIME | `5m` | edit window | +| read-age | READONLY_AGE | | read-only age of comments, days | +| img-proxy | IMG_PROXY | `false` | enable http->https proxy for images | +| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit | +| admin-passwd | ADMIN_PASSWD | none (disabled) | password for `admin` basic auth | +| dbg | DEBUG | `false` | debug mode | * command line parameters are long form `--=value`, i.e. `--site=https://demo.remark42.com` * _multi_ parameters separated by `,` in the environment or repeated with command line key, like `--site=s1 --site=s2 ...` @@ -602,6 +607,13 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i * `GET /api/v1/rss/site?site=site-id` - rss feed for given site * `GET /api/v1/rss/reply?site=site-id&user=user-id` - rss feed for replies to user's comments +### Images management + +* `GET /api/v1/picture/{user}/{id}` - load stored image +* `POST /api/v1/picture` - upload and store image, uses post form with `FormFile("file")`. returns `{"id": user/imgid}` _auth required_ + +_returned id should be appended to load image url on caller side_ + ### Admin * `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`. diff --git a/backend/app/cmd/avatar.go b/backend/app/cmd/avatar.go index b93d6b32..5c7e9a92 100644 --- a/backend/app/cmd/avatar.go +++ b/backend/app/cmd/avatar.go @@ -31,6 +31,7 @@ type AvatarMigrator interface { type avatarMigrator struct{} +// Migrate from one avatar store to another. Can be used to convert between stores func (a avatarMigrator) Migrate(dst, src avatar.Store) (int, error) { return avatar.Migrate(dst, src) } diff --git a/backend/app/cmd/cleanup.go b/backend/app/cmd/cleanup.go index c7b3e3e5..bbd29417 100644 --- a/backend/app/cmd/cleanup.go +++ b/backend/app/cmd/cleanup.go @@ -181,7 +181,7 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error) Info store.PostInfo `json:"info,omitempty"` }{} - if err := json.NewDecoder(r.Body).Decode(&commentsWithInfo); err != nil { + if err = json.NewDecoder(r.Body).Decode(&commentsWithInfo); err != nil { return nil, errors.Wrapf(err, "can't decode list of comments for %s", postURL) } return commentsWithInfo.Comments, nil diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 6579398a..160aec66 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -15,7 +15,7 @@ import ( bolt "github.com/coreos/bbolt" log "github.com/go-pkgz/lgr" - auth_cache "github.com/patrickmn/go-cache" + authcache "github.com/patrickmn/go-cache" "github.com/pkg/errors" "github.com/go-pkgz/auth" @@ -32,6 +32,7 @@ import ( "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/store/engine" + "github.com/umputun/remark/backend/app/store/image" "github.com/umputun/remark/backend/app/store/service" ) @@ -43,6 +44,7 @@ type ServerCommand struct { Mongo MongoGroup `group:"mongo" namespace:"mongo" env-namespace:"MONGO"` Admin AdminGroup `group:"admin" namespace:"admin" env-namespace:"ADMIN"` Notify NotifyGroup `group:"notify" namespace:"notify" env-namespace:"NOTIFY"` + Image ImageGroup `group:"image" namespace:"image" env-namespace:"IMAGE"` SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"` Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` @@ -93,6 +95,20 @@ type StoreGroup struct { } `group:"bolt" namespace:"bolt" env-namespace:"BOLT"` } +// ImageGroup defines options group for store pictures +type ImageGroup struct { + Type string `long:"type" env:"TYPE" description:"type of storage" choice:"fs" choice:"bolt" choice:"mongo" default:"fs"` + 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"` + Partitions int `long:"partitions" env:"PARTITIONS" default:"100" description:"partitions (subdirs)"` + } `group:"fs" namespace:"fs" env-namespace:"FS"` + 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"` +} + // AvatarGroup defines options group for avatar params type AvatarGroup struct { Type string `long:"type" env:"TYPE" description:"type of avatar storage" choice:"fs" choice:"bolt" choice:"mongo" default:"fs"` @@ -162,6 +178,7 @@ type serverApp struct { dataService *service.DataStore avatarStore avatar.Store notifyService *notify.Service + imageService *image.Service terminated chan struct{} } @@ -182,6 +199,7 @@ func (s *ServerCommand) Execute(args []string) error { app, err := s.newServerApp() if err != nil { log.Printf("[PANIC] failed to setup application, %+v", err) + return err } if err = app.run(ctx); err != nil { log.Printf("[ERROR] remark terminated with error %+v", err) @@ -214,6 +232,11 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { return nil, errors.Wrap(err, "failed to make admin store") } + imageService, err := s.makePicturesStore() + if err != nil { + return nil, errors.Wrap(err, "failed to make pictures store") + } + dataService := &service.DataStore{ Interface: storeEngine, EditDuration: s.EditDuration, @@ -221,6 +244,7 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { MaxCommentSize: s.MaxCommentSize, MaxVotes: s.MaxVotes, PositiveScore: s.PositiveScore, + ImageService: imageService, TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}), RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}), } @@ -276,15 +300,16 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { NotifyService: notifyService, SSLConfig: sslConfig, UpdateLimiter: s.UpdateLimit, + ImageService: imageService, } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore var devAuth *provider.DevAuthServer if s.Auth.Dev { - da, err := authenticator.DevAuth() - if err != nil { - return nil, errors.Wrap(err, "can't make dev oauth2 server") + da, errDevAuth := authenticator.DevAuth() + if errDevAuth != nil { + return nil, errors.Wrap(errDevAuth, "can't make dev oauth2 server") } devAuth = da } @@ -298,6 +323,7 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { dataService: dataService, avatarStore: avatarStore, notifyService: notifyService, + imageService: imageService, terminated: make(chan struct{}), }, nil } @@ -323,12 +349,17 @@ func (a *serverApp) run(ctx context.Context) error { log.Printf("[WARN] failed to close avatar store, %s", e) } a.notifyService.Close() + a.imageService.Close() log.Print("[INFO] shutdown completed") }() + a.activateBackup(ctx) // runs in goroutine for each site if a.Auth.Dev { go a.devAuth.Run(context.Background()) // dev oauth2 server on :8084 } + + go a.imageService.Cleanup(ctx) // pictures cleanup for staging images + a.restSrv.Run(a.Port) close(a.terminated) return nil @@ -405,6 +436,25 @@ func (s *ServerCommand) makeAvatarStore() (avatar.Store, error) { return nil, errors.Errorf("unsupported avatar store type %s", s.Avatar.Type) } +func (s *ServerCommand) makePicturesStore() (*image.Service, error) { + switch s.Image.Type { + 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, + }, + TTL: s.EditDuration + time.Second, // add extra second to image TTL for staging + }, nil + } + return nil, errors.Errorf("unsupported pictures store type %s", s.Image.Type) +} + func (s *ServerCommand) makeAdminStore() (admin.Store, error) { log.Printf("[INFO] make admin store, type=%s", s.Admin.Type) @@ -475,6 +525,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) { providers++ } if s.Auth.Dev { + log.Print("[INFO] dev access enabled") authenticator.AddProvider("dev", "", "") providers++ } @@ -585,17 +636,19 @@ func (s *ServerCommand) makeAuthenticator(ds *service.DataStore, avas avatar.Sto // authRefreshCache used by authenticator to minimize repeatable token refreshes type authRefreshCache struct { - *auth_cache.Cache + *authcache.Cache } func newAuthRefreshCache() *authRefreshCache { - return &authRefreshCache{Cache: auth_cache.New(5*time.Minute, 10*time.Minute)} + return &authRefreshCache{Cache: authcache.New(5*time.Minute, 10*time.Minute)} } +// Get implements cache getter with key converted to string func (c *authRefreshCache) Get(key interface{}) (interface{}, bool) { return c.Cache.Get(key.(string)) } +// Set implements cache setter with key converted to string func (c *authRefreshCache) Set(key, value interface{}) { - c.Cache.Set(key.(string), value, auth_cache.DefaultExpiration) + c.Cache.Set(key.(string), value, authcache.DefaultExpiration) } diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go index 9e0f2309..6b56319e 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -24,7 +24,7 @@ import ( ) func TestServerApp(t *testing.T) { - app, ctx := prepServerApp(t, 500*time.Millisecond, func(o ServerCommand) ServerCommand { + app, ctx := prepServerApp(t, 1500*time.Millisecond, func(o ServerCommand) ServerCommand { o.Port = 18080 return o }) diff --git a/backend/app/main.go b/backend/app/main.go index de745613..5e00c160 100644 --- a/backend/app/main.go +++ b/backend/app/main.go @@ -62,10 +62,10 @@ func main() { func setupLog(dbg bool) { if dbg { - log.Setup(log.Debug, log.CallerFile, log.Msec, log.LevelBraces, log.CallerIgnore("logger")) + log.Setup(log.Debug, log.CallerFile, log.CallerFunc, log.Msec, log.LevelBraces) return } - log.Setup(log.Msec, log.LevelBraces, log.CallerPkg, log.CallerIgnore("logger", "rest")) + log.Setup(log.Msec, log.LevelBraces) } // getDump reads runtime stack and returns as a string diff --git a/backend/app/main_test.go b/backend/app/main_test.go index d9385171..219f9b73 100644 --- a/backend/app/main_test.go +++ b/backend/app/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "io/ioutil" "net/http" "os" @@ -11,20 +12,25 @@ import ( "time" log "github.com/go-pkgz/lgr" + "github.com/go-pkgz/repeater" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestMain(t *testing.T) { +func Test_Main(t *testing.T) { - os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", - "--avatar.fs.path=/tmp", "--port=18202", "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"} + dir, err := ioutil.TempDir(os.TempDir(), "remark42") + require.NoError(t, err) + defer os.RemoveAll(dir) + + os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp", + "--avatar.fs.path=" + dir, "--port=18222", "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"} go func() { - time.Sleep(500 * time.Millisecond) - err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM) - require.Nil(t, err) + time.Sleep(2000 * time.Millisecond) + e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + require.Nil(t, e) }() wg := sync.WaitGroup{} @@ -32,20 +38,29 @@ func TestMain(t *testing.T) { go func() { st := time.Now() main() - assert.True(t, time.Since(st).Seconds() < 1, "should take about 500msec") + assert.True(t, time.Since(st).Seconds() > 2, "should take 2s") wg.Done() }() - time.Sleep(200 * time.Millisecond) // let server start + var passed bool + err = repeater.NewDefault(10, time.Millisecond*200).Do(context.Background(), func() error { + resp, e := http.Get("http://localhost:18222/api/v1/ping") + if e != nil { + t.Logf("%+v", e) + return e + } + require.Nil(t, e) + defer resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + body, e := ioutil.ReadAll(resp.Body) + assert.Nil(t, e) + assert.Equal(t, "pong", string(body)) + passed = true + return nil + }) - // send ping - resp, err := http.Get("http://localhost:18202/api/v1/ping") - require.Nil(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - body, err := ioutil.ReadAll(resp.Body) - assert.Nil(t, err) - assert.Equal(t, "pong", string(body)) + assert.NoError(t, err) + assert.Equal(t, true, passed) wg.Wait() } diff --git a/backend/app/migrator/backup_test.go b/backend/app/migrator/backup_test.go index 781e95f4..b7d95d08 100644 --- a/backend/app/migrator/backup_test.go +++ b/backend/app/migrator/backup_test.go @@ -16,7 +16,8 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) { loc := "/tmp/remark-backups.test" defer os.RemoveAll(loc) - os.MkdirAll(loc, 0700) + assert.NoError(t, os.MkdirAll(loc, 0700)) + for i := 1; i <= 10; i++ { fname := fmt.Sprintf("%s/backup-site1-201712%02d.gz", loc, i) err := ioutil.WriteFile(fname, []byte("blah"), 0600) @@ -40,7 +41,7 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) { func TestBackup_MakeBackup(t *testing.T) { loc := "/tmp/remark-backups.test" defer os.RemoveAll(loc) - os.MkdirAll(loc, 0700) + assert.NoError(t, os.MkdirAll(loc, 0700)) bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}} fname, err := bk.makeBackup() @@ -56,7 +57,7 @@ func TestBackup_MakeBackup(t *testing.T) { func TestBackup_Do(t *testing.T) { loc := "/tmp/remark-backups.test" defer os.RemoveAll(loc) - os.MkdirAll(loc, 0700) + assert.NoError(t, os.MkdirAll(loc, 0700)) ctx, cancel := context.WithCancel(context.Background()) go func() { diff --git a/backend/app/migrator/disqus.go b/backend/app/migrator/disqus.go index bbe0b260..14a5dc89 100644 --- a/backend/app/migrator/disqus.go +++ b/backend/app/migrator/disqus.go @@ -105,7 +105,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) { if se.Name.Local == "thread" { stats.inpThreads++ thread := disqusThread{} - if err := decoder.DecodeElement(&thread, &se); err != nil { + if err = decoder.DecodeElement(&thread, &se); err != nil { log.Printf("[WARN] can't decode disqus thread, %s", err) stats.failedThreads++ continue @@ -116,7 +116,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) { if se.Name.Local == "post" { stats.inpComments++ comment := disqusComment{} - if err := decoder.DecodeElement(&comment, &se); err != nil { + if err = decoder.DecodeElement(&comment, &se); err != nil { log.Printf("[WARN] can't decode disqus comment, %s", err) stats.failedPosts++ continue diff --git a/backend/app/migrator/native.go b/backend/app/migrator/native.go index 99cf9d34..40db2a17 100644 --- a/backend/app/migrator/native.go +++ b/backend/app/migrator/native.go @@ -15,7 +15,7 @@ import ( "github.com/umputun/remark/backend/app/store/service" ) -const natvieVersion = 1 +const nativeVersion = 1 const defaultConcurrent = 8 // Native implements exporter and importer for internal store format @@ -50,7 +50,7 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) { for i := len(topics) - 1; i >= 0; i-- { // topics from List sorted in opposite direction topic := topics[i] comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time") - if err != nil { + if e != nil { return commentsCount, e } @@ -75,13 +75,13 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) { // exportMeta appends user and post metas to exported stream func (n *Native) exportMeta(siteID string, w io.Writer) (err error) { - m := meta{Version: natvieVersion} + m := meta{Version: nativeVersion} m.Users, m.Posts, err = n.DataStore.Metas(siteID) if err != nil { return errors.Wrap(err, "can't get meta") } - if err := json.NewEncoder(w).Encode(m); err != nil { + if err = json.NewEncoder(w).Encode(m); err != nil { return errors.Wrap(err, "can't encode meta") } return nil @@ -96,7 +96,7 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) { return 0, errors.Wrapf(err, "failed to import meta for site %s", siteID) } - if m.Version != natvieVersion && m.Version != 0 { // this version allows back compatibility with 0 version + if m.Version != nativeVersion && m.Version != 0 { // this version allows back compatibility with 0 version return 0, errors.Errorf("unexpected import file version %d", m.Version) } @@ -134,9 +134,9 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) { log.Printf("[WARN] can't write %+v to store, %s", comment, e) return } - n := atomic.AddInt64(&comments, 1) - if n%1000 == 0 { - log.Printf("[DEBUG] imported %d comments", n) + num := atomic.AddInt64(&comments, 1) + if num%1000 == 0 { + log.Printf("[DEBUG] imported %d comments", num) } }) diff --git a/backend/app/migrator/wordpress.go b/backend/app/migrator/wordpress.go index 90dce39b..c1199ea7 100644 --- a/backend/app/migrator/wordpress.go +++ b/backend/app/migrator/wordpress.go @@ -39,6 +39,7 @@ type wpTime struct { time time.Time } +// UnmarshalXML decoding xml with time in WP format func (w *wpTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var v string if err := d.DecodeElement(&v, &start); err != nil { @@ -111,7 +112,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment { if el.Name.Local == "item" { stats.inpItems++ item := wpItem{} - if err := decoder.DecodeElement(&item, &el); err != nil { + if err = decoder.DecodeElement(&item, &el); err != nil { log.Printf("[WARN] Can't decode item, %s", err) stats.failedItems++ continue diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index 67b1b7bb..886fabad 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -29,10 +29,11 @@ type Destination interface { Send(ctx context.Context, req request) error } -// Store defines the minimal interface accessing stored commens used by notifier +// Store defines the minimal interface accessing stored comments used by notifier type Store interface { Get(locator store.Locator, id string) (store.Comment, error) } + type request struct { comment store.Comment parent store.Comment diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 8c429d5b..35dd2a21 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/umputun/remark/backend/app/store" ) @@ -71,6 +72,7 @@ func TestTelegram_Send(t *testing.T) { tb, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/") assert.NotNil(t, err, "should failed") err = tb.Send(context.TODO(), request{comment: c, parent: cp}) + require.NotNil(t, err) assert.Contains(t, err.Error(), "unexpected telegram status code 404", "send on broken tg") assert.Equal(t, "telegram: @remark_test", tb.String()) diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 6f634e29..cba6472a 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -115,14 +115,14 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { return } - if err := a.dataService.DeleteUser(claims.Audience, claims.User.ID); err != nil { + if err = a.dataService.DeleteUser(claims.Audience, claims.User.ID); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user", rest.ErrNoAccess) return } if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil { - avatartStore := a.authenticator.AvatarProxy().Store - if err := avatartStore.Remove(path.Base(claims.User.Picture)); err != nil { + avatarStore := a.authenticator.AvatarProxy().Store + if err = avatarStore.Remove(path.Base(claims.User.Picture)); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar", rest.ErrInternal) return } diff --git a/backend/app/rest/api/admin_test.go b/backend/app/rest/api/admin_test.go index d4848ab8..d3021b2d 100644 --- a/backend/app/rest/api/admin_test.go +++ b/backend/app/rest/api/admin_test.go @@ -113,11 +113,13 @@ func TestAdmin_Title(t *testing.T) { srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second}) tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.String() == "/post1" { - w.Write([]byte("post1 blah 123 2222")) + _, err := w.Write([]byte("post1 blah 123 2222")) + assert.NoError(t, err) return } if r.URL.String() == "/post2" { - w.Write([]byte("post2 blah 123 2222")) + _, err := w.Write([]byte("post2 blah 123 2222")) + assert.NoError(t, err) return } w.WriteHeader(404) diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index ad788e5e..373329ed 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -30,6 +30,7 @@ import ( "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/image" "github.com/umputun/remark/backend/app/store/service" ) @@ -44,6 +45,7 @@ type Rest struct { CommentFormatter *store.CommentFormatter Migrator *Migrator NotifyService *notify.Service + ImageService *image.Service WebRoot string RemarkURL string @@ -80,6 +82,7 @@ func (s *Rest) Run(port int) { s.lock.Lock() s.httpServer = s.makeHTTPServer(port, s.routes()) + s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") s.lock.Unlock() err := s.httpServer.ListenAndServe() @@ -89,7 +92,10 @@ func (s *Rest) Run(port int) { s.lock.Lock() s.httpsServer = s.makeHTTPSServer(s.SSLConfig.Port, s.routes()) + s.httpsServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") + s.httpServer = s.makeHTTPServer(port, s.httpToHTTPSRouter()) + s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") s.lock.Unlock() go func() { @@ -106,7 +112,11 @@ func (s *Rest) Run(port int) { m := s.makeAutocertManager() s.lock.Lock() s.httpsServer = s.makeHTTPSAutocertServer(s.SSLConfig.Port, s.routes(), m) + s.httpsServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") + s.httpServer = s.makeHTTPServer(port, s.httpChallengeRouter(m)) + s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN") + s.lock.Unlock() go func() { @@ -219,6 +229,7 @@ func (s *Rest) routes() chi.Router { ropen.Get("/config", s.configCtrl) ropen.Post("/preview", s.previewCommentCtrl) ropen.Get("/info", s.infoCtrl) + ropen.Get("/picture/{user}/{id}", s.loadPictureCtrl) ropen.Mount("/rss", s.rssRoutes()) ropen.Mount("/img", s.ImageProxy.Routes()) @@ -251,14 +262,27 @@ func (s *Rest) routes() chi.Router { rauth.Put("/comment/{id}", s.updateCommentCtrl) rauth.Post("/comment", s.createCommentCtrl) rauth.With(rejectAnonUser).Put("/vote/{id}", s.voteCtrl) - rauth.Post("/deleteme", s.deleteMeCtrl) + rauth.With(rejectAnonUser).Post("/deleteme", s.deleteMeCtrl) }) + + rapi.Group(func(rauth chi.Router) { + lmt := 10.0 + if s.UpdateLimiter > 0 { + lmt = s.UpdateLimiter + } + rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(lmt, nil))) + rauth.Use(authMiddleware.Auth) + rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler) + rauth.With(rejectAnonUser).Post("/picture", s.savePictureCtrl) + }) + }) // 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"} + allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", + "/img", "/avatar", "/picture"} for i := range allowed { allowed[i] = "Allow: /api/v1" + allowed[i] } diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index add6fd9d..f03f335e 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -130,14 +130,9 @@ func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation) return } + if err != nil { - code := rest.ErrCommentRejected - switch { - case strings.HasPrefix(err.Error(), "too late to edit"): - code = rest.ErrCommentEditExpired - case strings.HasPrefix(err.Error(), "parent comment with reply can't be edited"): - code = rest.ErrCommentEditChanged - } + code := s.parseError(err, rest.ErrCommentRejected) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't update comment", code) return } @@ -178,17 +173,7 @@ func (s *Rest) voteCtrl(w http.ResponseWriter, r *http.Request) { comment, err := s.DataService.Vote(locator, id, user.ID, vote) if err != nil { - code := rest.ErrVoteRejected - switch { - case strings.Contains(err.Error(), "can not vote for his own comment"): - code = rest.ErrVoteSelf - case strings.Contains(err.Error(), "already voted for"): - code = rest.ErrVoteDbl - case strings.Contains(err.Error(), "maximum number of votes exceeded for comment"): - code = rest.ErrVoteMax - case strings.Contains(err.Error(), "minimal score reached for comment"): - code = rest.ErrVoteMinScore - } + code := s.parseError(err, rest.ErrVoteRejected) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't vote for comment", code) return } @@ -228,14 +213,14 @@ func (s *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) { // get comments in 100 in each paginated request for i := 0; i < 100; i++ { - comments, err := s.DataService.User(siteID, user.ID, 100, i*100) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get user comments", rest.ErrInternal) + comments, errUser := s.DataService.User(siteID, user.ID, 100, i*100) + if errUser != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal) return } - b, err := json.Marshal(comments) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't marshal user comments", rest.ErrInternal) + b, errUser := json.Marshal(comments) + if errUser != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't marshal user comments", rest.ErrInternal) return } @@ -285,6 +270,31 @@ func (s *Rest) deleteMeCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link}) } +// POST /image - save image with form request +func (s *Rest) savePictureCtrl(w http.ResponseWriter, r *http.Request) { + user := rest.MustGetUserInfo(r) + + if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { // 5M max memory, if bigger will make a file + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode) + return + } + + file, header, 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) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't save image", rest.ErrInternal) + return + } + + render.JSON(w, r, R.JSON{"id": id}) +} + func (s *Rest) isReadOnly(locator store.Locator) bool { if s.ReadOnlyAge > 0 { // check RO by age @@ -294,3 +304,28 @@ func (s *Rest) isReadOnly(locator store.Locator) bool { } return s.DataService.IsReadOnly(locator) // ro manually } + +func (s *Rest) parseError(err error, defaultCode int) (code int) { + code = defaultCode + + switch { + // voting errors + case strings.Contains(err.Error(), "can not vote for his own comment"): + code = rest.ErrVoteSelf + case strings.Contains(err.Error(), "already voted for"): + code = rest.ErrVoteDbl + case strings.Contains(err.Error(), "maximum number of votes exceeded for comment"): + code = rest.ErrVoteMax + case strings.Contains(err.Error(), "minimal score reached for comment"): + code = rest.ErrVoteMinScore + + // edit errors + case strings.HasPrefix(err.Error(), "too late to edit"): + code = rest.ErrCommentEditExpired + case strings.HasPrefix(err.Error(), "parent comment with reply can't be edited"): + code = rest.ErrCommentEditChanged + + } + + return code +} diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 5f7cb1d2..f075b4ec 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -1,20 +1,29 @@ package api import ( + "bytes" "compress/gzip" "encoding/json" "fmt" + "io" "io/ioutil" + "mime/multipart" "net/http" + "os" + "strconv" "strings" "testing" "time" + "github.com/go-pkgz/lgr" R "github.com/go-pkgz/rest" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" + "github.com/umputun/remark/backend/app/store/image" ) func TestRest_Create(t *testing.T) { @@ -499,3 +508,164 @@ func TestRest_DeleteMe(t *testing.T) { assert.Nil(t, err) assert.Equal(t, 401, resp.StatusCode) } + +func TestRest_SavePictureCtrl(t *testing.T) { + ts, _, teardown := startupT(t) + defer teardown() + + // save picture + savePic := func(name string) (id string) { + r := strings.NewReader("file content 123") + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + fileWriter, err := bodyWriter.CreateFormFile("file", name) + require.NoError(t, err) + _, err = io.Copy(fileWriter, r) + require.NoError(t, err) + contentType := bodyWriter.FormDataContentType() + require.NoError(t, bodyWriter.Close()) + + client := http.Client{} + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture", ts.URL), bodyBuf) + require.NoError(t, err) + req.Header.Add("Content-Type", contentType) + req.Header.Add("X-JWT", devToken) + resp, err := client.Do(req) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + require.Nil(t, err) + + m := map[string]string{} + err = json.Unmarshal(body, &m) + assert.NoError(t, err) + assert.True(t, m["id"] != "") + return m["id"] + } + + id := savePic("picture.png") + resp, err := http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + require.Nil(t, err) + assert.Equal(t, "file content 123", string(body)) + assert.Equal(t, "image/png", resp.Header.Get("Content-Type")) + + id = savePic("picture.gif") + resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "image/gif", resp.Header.Get("Content-Type")) + + id = savePic("picture.jpg") + resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type")) + + id = savePic("picture.blah") + resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, id)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "image/*", resp.Header.Get("Content-Type")) + + resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/blah/pic.blah", ts.URL)) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) +} + +func TestRest_CreateWithPictures(t *testing.T) { + ts, svc, teardown := startupT(t) + defer func() { + teardown() + os.RemoveAll("/tmp/remark42") + }() + lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc) + + svc.ImageService = &image.Service{ + Store: &image.FileSystem{ + Staging: "/tmp/remark42/images.staging", + Location: "/tmp/remark42/images", + MaxSize: 1000, + }, + TTL: time.Millisecond * 100, + } + svc.DataService.EditDuration = time.Millisecond * 100 + svc.DataService.ImageService = svc.ImageService + + uploadPicture := func(file, content string) (id string) { + r := strings.NewReader(content) + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + fileWriter, err := bodyWriter.CreateFormFile("file", file) + require.NoError(t, err) + _, err = io.Copy(fileWriter, r) + require.NoError(t, err) + contentType := bodyWriter.FormDataContentType() + require.NoError(t, bodyWriter.Close()) + client := http.Client{} + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture", ts.URL), bodyBuf) + require.NoError(t, err) + req.Header.Add("Content-Type", contentType) + req.Header.Add("X-JWT", devToken) + resp, err := client.Do(req) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + + body, err := ioutil.ReadAll(resp.Body) + require.Nil(t, err) + m := map[string]string{} + err = json.Unmarshal(body, &m) + assert.NoError(t, err) + assert.Contains(t, m["id"], ".png") + return m["id"] + } + + id1 := uploadPicture("pic1.png", "file content 123") + id2 := uploadPicture("pic2.png", "file content 12345") + id3 := uploadPicture("pic3.png", "file content xyz12365789") + + text := fmt.Sprintf(`text 123 ![](/api/v1/picture/%s) *xxx* ![](/api/v1/picture/%s) ![](/api/v1/picture/%s)`, id1, id2, id3) + body := fmt.Sprintf(`{"text": "%s", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text) + + resp, err := post(t, ts.URL+"/api/v1/comment", body) + assert.Nil(t, err) + b, err := ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode, string(b)) + + _, err = os.Stat("/tmp/remark42/images/" + id1) + assert.NotNil(t, err, "not moved from staging yet") + + time.Sleep(300 * time.Millisecond) + _, err = os.Stat("/tmp/remark42/images/" + id1) + assert.NoError(t, err, "moved from staging") + _, err = os.Stat("/tmp/remark42/images/" + id2) + assert.NoError(t, err, "moved from staging") + _, err = os.Stat("/tmp/remark42/images/" + id3) + assert.NoError(t, err, "moved from staging") +} + +func TestRest_parseError(t *testing.T) { + tbl := []struct { + err error + res int + }{ + {errors.New("can not vote for his own comment"), rest.ErrVoteSelf}, + {errors.New("already voted for"), rest.ErrVoteDbl}, + {errors.New("maximum number of votes exceeded for comment"), rest.ErrVoteMax}, + {errors.New("minimal score reached for comment"), rest.ErrVoteMinScore}, + {errors.New("too late to edit"), rest.ErrCommentEditExpired}, + {errors.New("parent comment with reply can't be edited"), rest.ErrCommentEditChanged}, + {errors.New("blah blah"), rest.ErrInternal}, + } + + svc := Rest{} + for n, tt := range tbl { + t.Run(strconv.Itoa(n), func(t *testing.T) { + res := svc.parseError(tt.err, rest.ErrInternal) + assert.Equal(t, tt.res, res) + }) + } +} diff --git a/backend/app/rest/api/rest_public.go b/backend/app/rest/api/rest_public.go index 4d2c87d4..1b0c0338 100644 --- a/backend/app/rest/api/rest_public.go +++ b/backend/app/rest/api/rest_public.go @@ -1,8 +1,9 @@ package api import ( - "crypto/sha1" //nolint + "crypto/sha1" // nolint "encoding/base64" + "io" "net/http" "strconv" "strings" @@ -274,12 +275,9 @@ func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) { // key could be long for multiple posts, make it sha1 k := URLKey(r) + strings.Join(posts, ",") - hasher := sha1.New() //nolint - if _, err := hasher.Write([]byte(k)); err != nil { - rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't make sha1 for list of urls", rest.ErrInternal) - return - } - sha := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) + h := sha1.Sum([]byte(k)) //nolint + sha := base64.URLEncoding.EncodeToString(h[:]) + key := cache.NewKey(siteID).ID(sha).Scopes(siteID) data, err := s.Cache.Get(key, func() ([]byte, error) { counts, e := s.DataService.Counts(siteID, posts) @@ -330,3 +328,46 @@ func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[WARN] can't render posts lits for site %s", siteID) } } + +// GET /picture/{user}/{id} - get picture +func (s *Rest) loadPictureCtrl(w http.ResponseWriter, r *http.Request) { + + imgContentType := func(img string) string { + img = strings.ToLower(img) + switch { + case strings.HasSuffix(img, ".png"): + return "image/png" + case strings.HasSuffix(img, ".jpg") || strings.HasSuffix(img, ".jpeg"): + return "image/jpeg" + case strings.HasSuffix(img, ".gif"): + return "image/gif" + } + return "image/*" + } + + id := chi.URLParam(r, "user") + "/" + chi.URLParam(r, "id") + imgRdr, size, err := s.ImageService.Load(id) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get image "+id, rest.ErrAssetNotFound) + return + } + // enforce client-side caching + etag := `"` + id + `"` + 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 + } + } + + defer imgRdr.Close() + + w.Header().Set("Content-Type", imgContentType(id)) + w.Header().Set("Content-Length", strconv.Itoa(int(size))) + w.WriteHeader(http.StatusOK) + if _, err = io.Copy(w, imgRdr); err != nil { + log.Printf("[WARN] can't send response to %s, %s", r.RemoteAddr, err) + } +} diff --git a/backend/app/rest/api/rest_public_test.go b/backend/app/rest/api/rest_public_test.go index 4285b384..e3343a8f 100644 --- a/backend/app/rest/api/rest_public_test.go +++ b/backend/app/rest/api/rest_public_test.go @@ -377,6 +377,37 @@ func TestRest_List(t *testing.T) { assert.Equal(t, 3, pi[1].Count) } +func TestRest_ListWithSkipAndLimit(t *testing.T) { + ts, _, teardown := startupT(t) + defer teardown() + + c1 := store.Comment{Text: "test test #1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}} + c3 := store.Comment{Text: "test test #3", ParentID: "p1", + Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah3"}} + + addComment(t, c1, ts) + addComment(t, c1, ts) + addComment(t, c1, ts) + addComment(t, c2, ts) + addComment(t, c2, ts) + addComment(t, c3, ts) + addComment(t, c3, ts) + + body, code := get(t, ts.URL+"/api/v1/list?site=radio-t&skip=1&limit=2") + assert.Equal(t, 200, code) + pi := []store.PostInfo{} + err := json.Unmarshal([]byte(body), &pi) + assert.Nil(t, err) + require.Equal(t, 2, len(pi)) + assert.Equal(t, "https://radio-t.com/blah2", pi[0].URL) + assert.Equal(t, 2, pi[0].Count) + assert.Equal(t, "https://radio-t.com/blah1", pi[1].URL) + assert.Equal(t, 3, pi[1].Count) +} + func TestRest_Config(t *testing.T) { ts, _, teardown := startupT(t) defer teardown() @@ -442,5 +473,5 @@ func TestRest_Robots(t *testing.T) { assert.Equal(t, 200, code) assert.Equal(t, "User-agent: *\nDisallow: /auth/\nDisallow: /api/\nAllow: /api/v1/find\n"+ "Allow: /api/v1/last\nAllow: /api/v1/id\nAllow: /api/v1/count\nAllow: /api/v1/counts\n"+ - "Allow: /api/v1/list\nAllow: /api/v1/config\nAllow: /api/v1/img\nAllow: /api/v1/avatar\n", string(body)) + "Allow: /api/v1/list\nAllow: /api/v1/config\nAllow: /api/v1/img\nAllow: /api/v1/avatar\nAllow: /api/v1/picture\n", string(body)) } diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index d21b1c6c..ef5108d7 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -29,6 +29,7 @@ import ( "github.com/umputun/remark/backend/app/store" adminstore "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/store/engine" + "github.com/umputun/remark/backend/app/store/image" "github.com/umputun/remark/backend/app/store/service" ) @@ -203,6 +204,7 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) { os.Remove(testDb) os.Remove(testHTML) os.RemoveAll("/tmp/ava-remark42") + os.RemoveAll("/tmp/pics-remark42") b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDb, SiteID: "radio-t"}) require.Nil(t, err) @@ -232,7 +234,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, + }, + TTL: time.Millisecond * 100, + }, ImageProxy: &proxy.Image{}, ReadOnlyAge: 10, CommentFormatter: store.NewCommentFormatter(&proxy.Image{}), @@ -258,6 +267,7 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) { os.Remove(testDb) os.Remove(testHTML) os.RemoveAll("/tmp/ava-remark42") + os.RemoveAll("/tmp/pics-remark42") } return ts, srv, teardown diff --git a/backend/app/rest/api/rss.go b/backend/app/rest/api/rss.go index fc1c473d..06d4e788 100644 --- a/backend/app/rest/api/rss.go +++ b/backend/app/rest/api/rss.go @@ -57,7 +57,7 @@ func (s *Rest) rssPostCommentsCtrl(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.WriteHeader(http.StatusOK) - if _, err := w.Write(data); err != nil { + if _, err = w.Write(data); err != nil { log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err) } } @@ -89,7 +89,7 @@ func (s *Rest) rssSiteCommentsCtrl(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.WriteHeader(http.StatusOK) - if _, err := w.Write(data); err != nil { + if _, err = w.Write(data); err != nil { log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err) } } @@ -141,7 +141,7 @@ func (s *Rest) rssRepliesCtrl(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.WriteHeader(http.StatusOK) - if _, err := w.Write(data); err != nil { + if _, err = w.Write(data); err != nil { log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err) } } diff --git a/backend/app/rest/proxy/image_test.go b/backend/app/rest/proxy/image_test.go index dc8eae0c..13d3a1b8 100644 --- a/backend/app/rest/proxy/image_test.go +++ b/backend/app/rest/proxy/image_test.go @@ -132,7 +132,8 @@ func imgHTTPServer(t *testing.T) *httptest.Server { t.Log("http img request", r.URL) w.Header().Add("Content-Length", "123") w.Header().Add("Content-Type", "image/png") - w.Write([]byte(fmt.Sprintf("%123s", "X"))) + _, err := w.Write([]byte(fmt.Sprintf("%123s", "X"))) + assert.NoError(t, err) return } if r.URL.Path == "/image/img-slow.png" { diff --git a/backend/app/store/engine/bolt_accessor.go b/backend/app/store/engine/bolt_accessor.go index 9076f874..2348958e 100644 --- a/backend/app/store/engine/bolt_accessor.go +++ b/backend/app/store/engine/bolt_accessor.go @@ -74,6 +74,7 @@ func NewBoltDB(options bolt.Options, sites ...BoltSite) (*BoltDB, error) { } result.dbs[site.SiteID] = db + log.Printf("[DEBUG] bolt store created for %s", site.SiteID) } return &result, nil } @@ -155,7 +156,7 @@ func (b *BoltDB) Find(locator store.Locator, sortFld string) (comments []store.C return bucket.ForEach(func(k, v []byte) error { comment := store.Comment{} - if e := json.Unmarshal(v, &comment); e != nil { + if e = json.Unmarshal(v, &comment); e != nil { return errors.Wrap(e, "failed to unmarshal") } comments = append(comments, comment) @@ -195,7 +196,7 @@ func (b *BoltDB) Last(siteID string, max int) (comments []store.Comment, err err } comment := store.Comment{} - if e := b.load(postBkt, []byte(commentID), &comment); e != nil { + if e = b.load(postBkt, []byte(commentID), &comment); e != nil { log.Printf("[WARN] can't load comment for %s from store %s", commentID, url) continue } @@ -335,11 +336,11 @@ func (b *BoltDB) User(siteID, userID string, limit, skip int) (comments []store. // retrieve comments for refs for _, v := range commentRefs { - url, commentID, e := b.parseRef([]byte(v)) - if e != nil { - return comments, errors.Wrapf(e, "can't parse reference %s", v) + url, commentID, errParse := b.parseRef([]byte(v)) + if errParse != nil { + return comments, errors.Wrapf(errParse, "can't parse reference %s", v) } - if c, e := b.Get(store.Locator{SiteID: siteID, URL: url}, commentID); e == nil { + if c, errRef := b.Get(store.Locator{SiteID: siteID, URL: url}, commentID); errRef == nil { comments = append(comments, c) } } diff --git a/backend/app/store/engine/bolt_admin.go b/backend/app/store/engine/bolt_admin.go index 579d34db..429ac991 100644 --- a/backend/app/store/engine/bolt_admin.go +++ b/backend/app/store/engine/bolt_admin.go @@ -29,19 +29,19 @@ func (b *BoltDB) Delete(locator store.Locator, commentID string, mode store.Dele } comment := store.Comment{} - if err := b.load(postBkt, []byte(commentID), &comment); err != nil { + if err = b.load(postBkt, []byte(commentID), &comment); err != nil { return errors.Wrapf(err, "can't load key %s from bucket %s", commentID, locator.URL) } // set deleted status and clear fields comment.SetDeleted(mode) - if err := b.save(postBkt, []byte(commentID), comment); err != nil { + if err = b.save(postBkt, []byte(commentID), comment); err != nil { return errors.Wrapf(err, "can't save deleted comment for key %s from bucket %s", commentID, locator.URL) } // delete from "last" bucket lastBkt := tx.Bucket([]byte(lastBucketName)) - if err := lastBkt.Delete([]byte(commentID)); err != nil { + if err = lastBkt.Delete([]byte(commentID)); err != nil { return errors.Wrapf(err, "can't delete key %s from bucket %s", commentID, lastBucketName) } @@ -200,8 +200,8 @@ func (b *BoltDB) IsBlocked(siteID string, userID string) (blocked bool) { return nil } - until, err := time.Parse(tsNano, string(val)) - if err != nil { + until, e := time.Parse(tsNano, string(val)) + if e != nil { blocked = false return nil } @@ -223,15 +223,15 @@ func (b *BoltDB) Blocked(siteID string) (users []store.BlockedUser, err error) { err = bdb.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(blocksBucketName)) return bucket.ForEach(func(k []byte, v []byte) error { - ts, e := time.ParseInLocation(tsNano, string(v), time.Local) - if e != nil { - return errors.Wrap(e, "can't parse block ts") + ts, errParse := time.ParseInLocation(tsNano, string(v), time.Local) + if errParse != nil { + return errors.Wrap(errParse, "can't parse block ts") } if time.Now().Before(ts) { // get user name from comment user section userName := "" - userComments, e := b.User(siteID, string(k), 1, 0) - if e == nil && len(userComments) > 0 { + userComments, errUser := b.User(siteID, string(k), 1, 0) + if errUser == nil && len(userComments) > 0 { userName = userComments[0].User.Name } users = append(users, store.BlockedUser{ID: string(k), Name: userName, Until: ts}) diff --git a/backend/app/store/engine/mongo.go b/backend/app/store/engine/mongo.go index 587e7bda..d36846d2 100644 --- a/backend/app/store/engine/mongo.go +++ b/backend/app/store/engine/mongo.go @@ -230,8 +230,8 @@ func (m *Mongo) Verified(siteID string) (ids []string, err error) { if err != nil { return nil, err } - for _, m := range metas { - ids = append(ids, m.ID) + for _, meta := range metas { + ids = append(ids, meta.ID) } return ids, nil } diff --git a/backend/app/store/image/fs_store.go b/backend/app/store/image/fs_store.go new file mode 100644 index 00000000..cc99aec7 --- /dev/null +++ b/backend/app/store/image/fs_store.go @@ -0,0 +1,167 @@ +package image + +import ( + "context" + "fmt" + "hash/crc64" + "io" + "math" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + log "github.com/go-pkgz/lgr" + "github.com/google/uuid" + "github.com/pkg/errors" +) + +// FileSystem provides image Store for local files. Saves and loads files from Location, restricts max size. +type FileSystem struct { + Location string + Staging string + MaxSize int + Partitions int + + crc struct { + *crc64.Table + sync.Once + mask string + divider uint64 + } +} + +// Save data from reader for given file name to local FS, staging directory. Returns id as user/uuid.ext +// Files partitioned across multiple subdirectories and the final path includes part, i.e. /location/user1/03/123-4567.png +func (f *FileSystem) Save(fileName string, userID string, r io.Reader) (id string, err error) { + + uid, err := uuid.NewUUID() + if err != nil { + return "", errors.Wrap(err, "can't make image uuid") + } + + id = path.Join(userID, uid.String()) + filepath.Ext(fileName) // make id as user/uuid.ext + dst := f.location(f.Staging, id) + + if err = os.MkdirAll(path.Dir(dst), 0700); err != nil { + return "", errors.Wrap(err, "can't make image directory") + } + + fh, err := os.Create(dst) + if err != nil { + return "", errors.Wrapf(err, "can't make image file %s", dst) + } + lr := io.LimitReader(r, int64(f.MaxSize)+1) + written, err := io.Copy(fh, lr) + if err != nil { + return "", errors.Wrapf(err, "can't write image file %s", dst) + } + if err = fh.Close(); err != nil { + return "", errors.Wrapf(err, "can't close image file %s", dst) + } + if written > int64(f.MaxSize) { + if err = os.Remove(dst); err != nil { + log.Printf("[WARN] can't remove image file %s, %v", dst, err) + } + return "", errors.Errorf("file %s is too large", fileName) + } + log.Printf("[DEBUG] file %s saved for image %s", fh.Name(), fileName) + return id, nil +} + +// Commit file stored in staging location by moving it to permanent location +func (f *FileSystem) Commit(id string) error { + log.Printf("[DEBUG] commit image %s", id) + stagingImage, permImage := f.location(f.Staging, id), f.location(f.Location, id) + + if err := os.MkdirAll(path.Dir(permImage), 0700); err != nil { + return errors.Wrap(err, "can't make image directory") + } + + err := os.Rename(stagingImage, permImage) + return errors.Wrapf(err, "failed to commit image %s", id) +} + +// Load image from FS. Uses id to get partition subdirectory. +// returns ReadCloser and caller should call close after processing completed. +func (f *FileSystem) Load(id string) (io.ReadCloser, int64, error) { + + // get image file by id. first try permanent location and if not found - staging + img := func(id string) (file string, st os.FileInfo, err error) { + file = f.location(f.Location, id) + st, err = os.Stat(file) + if err != nil { + file = f.location(f.Staging, id) + st, err = os.Stat(file) + } + return file, st, errors.Wrapf(err, "can't get image stats for %s", id) + } + + imgFile, st, err := img(id) + if err != nil { + return nil, 0, errors.Wrapf(err, "can't get image file for %s", id) + } + + fh, err := os.Open(imgFile) + if err != nil { + return nil, 0, errors.Wrapf(err, "can't load image %s", id) + } + return fh, st.Size(), nil +} + +// Cleanup runs scan of staging and removes old files based on ttl +func (f *FileSystem) Cleanup(ctx context.Context, ttl time.Duration) error { + + if _, err := os.Stat(f.Staging); os.IsNotExist(err) { + return nil + } + + err := filepath.Walk(f.Staging, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + age := time.Since(info.ModTime()) + if age > ttl { + log.Printf("[INFO] remove staging image %s, age %v", path, age) + return os.Remove(path) + } + return nil + }) + return errors.Wrap(err, "failed to cleanup images") +} + +// 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. +// Number of partitions defined by FileSystem.Partitions +func (f *FileSystem) location(base string, id string) string { + + partition := func(id string) string { + f.crc.Do(func() { + f.crc.Table = crc64.MakeTable(crc64.ECMA) + p := int(math.Round(math.Log10(float64(f.Partitions)))) + f.crc.mask = "%0" + strconv.Itoa(p) + "d" + f.crc.divider = uint64(math.Pow(10, float64(p))) + }) + checksum64 := crc64.Checksum([]byte(id), f.crc.Table) + partition := checksum64 % f.crc.divider + return fmt.Sprintf(f.crc.mask, partition) + } + + user, file := "unknown", id // default if no user in id + if elems := strings.Split(id, "/"); len(elems) == 2 { + user, file = elems[0], elems[1] // user in id + } + + if f.Partitions == 0 { + return path.Join(base, user, file) // avoid partition directory if 0 Partitions + } + + return path.Join(base, user, partition(id), file) +} diff --git a/backend/app/store/image/fs_store_test.go b/backend/app/store/image/fs_store_test.go new file mode 100644 index 00000000..39c2abad --- /dev/null +++ b/backend/app/store/image/fs_store_test.go @@ -0,0 +1,213 @@ +package image + +import ( + "context" + "io/ioutil" + "math/rand" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFsStore_Save(t *testing.T) { + svc, teardown := prepareImageTest(t) + defer teardown() + + id, err := svc.Save("file1.png", "user1", strings.NewReader("blah blah")) + assert.NoError(t, err) + assert.Contains(t, id, "user1/") + assert.Contains(t, id, ".png") + t.Log(id) + + img := svc.location(svc.Staging, id) + t.Log(img) + data, err := ioutil.ReadFile(img) + assert.NoError(t, err) + assert.Equal(t, "blah blah", string(data)) +} + +func TestFsStore_SaveAndCommit(t *testing.T) { + svc, teardown := prepareImageTest(t) + defer teardown() + + id, err := svc.Save("file1.png", "user1", strings.NewReader("blah blah")) + require.NoError(t, err) + err = svc.Commit(id) + require.NoError(t, err) + + imgStaging := svc.location(svc.Staging, id) + _, err = os.Stat(imgStaging) + assert.NotNil(t, err, "no file on staging anymore") + + img := svc.location(svc.Location, id) + t.Log(img) + data, err := ioutil.ReadFile(img) + assert.NoError(t, err) + assert.Equal(t, "blah blah", string(data)) +} + +func TestFsStore_SaveTooLarge(t *testing.T) { + svc, teardown := prepareImageTest(t) + defer teardown() + svc.MaxSize = 5 + _, err := svc.Save("blah_ff1.png", "user2", strings.NewReader("blah blah")) + 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", strings.NewReader("blah blah")) + assert.NoError(t, err) + t.Log(id) + + r, sz, err := svc.Load(id) + assert.NoError(t, err) + defer func() { assert.NoError(t, r.Close()) }() + data, err := ioutil.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "blah blah", string(data)) + assert.Equal(t, int64(9), sz) + _, _, err = svc.Load("abcd") + assert.NotNil(t, err) +} + +func TestFsStore_LoadAfterCommit(t *testing.T) { + + svc, teardown := prepareImageTest(t) + defer teardown() + + id, err := svc.Save("blah_ff1.png", "user1", strings.NewReader("blah blah")) + assert.NoError(t, err) + t.Log(id) + err = svc.Commit(id) + require.NoError(t, err) + + r, sz, err := svc.Load(id) + assert.NoError(t, err) + defer func() { assert.NoError(t, r.Close()) }() + data, err := ioutil.ReadAll(r) + assert.NoError(t, err) + assert.Equal(t, "blah blah", string(data)) + assert.Equal(t, int64(9), sz) + _, _, err = svc.Load("abcd") + assert.NotNil(t, err) +} + +func TestFsStore_location(t *testing.T) { + tbl := []struct { + partitions int + id, res string + }{ + {10, "u1/abcdefg.png", "/tmp/u1/4/abcdefg.png"}, + {10, "u2/abcdefe", "/tmp/u2/0/abcdefe"}, + {10, "u3/12345", "/tmp/u3/4/12345"}, + {100, "12345", "/tmp/unknown/69/12345"}, + {100, "xyzz", "/tmp/unknown/58/xyzz"}, + {100, "u4/6851dcde6024e03258a66705f29e14b506048c74.png", "/tmp/u4/07/6851dcde6024e03258a66705f29e14b506048c74.png"}, + {5, "user/6851dcde6024e03258a66705f29e14b506048c74.png", "/tmp/user/1/6851dcde6024e03258a66705f29e14b506048c74.png"}, + {5, "aa-xxxyz.png", "/tmp/unknown/3/aa-xxxyz.png"}, + {0, "12345", "/tmp/unknown/12345"}, + {0, "user/12345", "/tmp/user/12345"}, + } + for n, tt := range tbl { + t.Run(strconv.Itoa(n), func(t *testing.T) { + svc := FileSystem{Location: "/tmp", Partitions: tt.partitions} + assert.Equal(t, tt.res, svc.location("/tmp", tt.id)) + }) + } + + // generate random names and make sure partition never runs out of allowed + letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + randomID := func(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return "user1" + "/" + string(b) + } + + svc := FileSystem{Location: "/tmp", Partitions: 10} + for i := 0; i < 1000; i++ { + v := randomID(rand.Intn(64)) + location := svc.location("/tmp", v) + elems := strings.Split(location, "/") + p, err := strconv.Atoi(elems[3]) + require.NoError(t, err, location) + assert.True(t, p >= 0 && p < 10) + } +} + +func TestFsStore_Cleanup(t *testing.T) { + svc, teardown := prepareImageTest(t) + defer teardown() + + save := func(file string, user string, content string) (path string) { + id, err := svc.Save(file, user, strings.NewReader(content)) + require.NoError(t, err) + img := svc.location(svc.Staging, id) + data, err := ioutil.ReadFile(img) + require.NoError(t, err) + require.Equal(t, content, string(data)) + return img + } + + // save 3 images to staging + img1 := save("blah_ff1.png", "user1", "blah blah1") + time.Sleep(100 * time.Millisecond) + img2 := save("blah_ff2.png", "user1", "blah blah2") + time.Sleep(100 * time.Millisecond) + img3 := save("blah_ff3.png", "user2", "blah blah3") + + time.Sleep(100 * time.Millisecond) // make first image expired + err := svc.Cleanup(context.Background(), time.Millisecond*300) + assert.NoError(t, err) + + _, err = os.Stat(img1) + assert.NotNil(t, err, "no file on staging anymore") + _, err = os.Stat(img2) + assert.NoError(t, err, "file on staging") + _, err = os.Stat(img3) + assert.NoError(t, err, "file on staging") + + time.Sleep(200 * time.Millisecond) // make all images expired + err = svc.Cleanup(context.Background(), time.Millisecond*300) + assert.NoError(t, err) + + _, err = os.Stat(img2) + assert.NotNil(t, err, "no file on staging anymore") + _, err = os.Stat(img3) + assert.NotNil(t, err, "no file on staging anymore") +} + +func prepareImageTest(t *testing.T) (svc *FileSystem, teardown func()) { + loc, err := ioutil.TempDir("", "test_image_r42") + require.NoError(t, err, "failed to make temp dir") + + staging, err := ioutil.TempDir("", "test_image_r42.staging") + require.NoError(t, err, "failed to make temp staging dir") + + svc = &FileSystem{ + Location: loc, + Staging: staging, + Partitions: 100, + MaxSize: 50, + } + + teardown = func() { + defer func() { + assert.NoError(t, os.RemoveAll(loc)) + assert.NoError(t, os.RemoveAll(staging)) + }() + } + + return svc, teardown +} diff --git a/backend/app/store/image/image.go b/backend/app/store/image/image.go new file mode 100644 index 00000000..69230375 --- /dev/null +++ b/backend/app/store/image/image.go @@ -0,0 +1,127 @@ +// Package image handles storing, resizing and retrieval of images +// Provides Store with Save and Load and one implementation on top of local file system. +// Service object encloses Store and add common methods, this is the one consumer should use +package image + +//go:generate sh -c "mockgen -source=image.go -package=image > image_mock.go" + +import ( + "context" + "io" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/PuerkitoBio/goquery" + log "github.com/go-pkgz/lgr" + "github.com/pkg/errors" +) + +// Store defines interface for saving and loading pictures. +// Declares two-stage save with commit +type Store interface { + Save(fileName string, userID string, r io.Reader) (id string, err error) // get name and reader and returns ID of stored image + Commit(id string) error // move image from staging to permanent + Load(id string) (io.ReadCloser, int64, error) // load image by ID. Caller has to close the reader. + Cleanup(ctx context.Context, ttl time.Duration) error // run removal loop for old images on staging +} + +// Service extends Store with common functions needed for any store implementation +type Service struct { + Store + TTL time.Duration // for how long file allowed on staging + ImageAPI string // image api matching path + + wg sync.WaitGroup + submitCh chan submitReq + once sync.Once + term int32 +} + +const submitQueueSize = 5000 + +type submitReq struct { + idsFn func() (ids []string) + TS time.Time +} + +// Submit multiple ids via function for delayed commit +func (s *Service) Submit(idsFn func() []string) { + if idsFn == nil || s == nil { + return + } + + s.once.Do(func() { + log.Printf("[DEBUG] image submitter activated") + s.submitCh = make(chan submitReq, submitQueueSize) + s.wg.Add(1) + go func() { + defer s.wg.Done() + for req := range s.submitCh { + // wait for TTL expiration with emergency pass on term + for atomic.LoadInt32(&s.term) == 0 && time.Since(req.TS) <= s.TTL { + 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 { + log.Printf("[WARN] failed to commit image %s", id) + } + } + } + log.Printf("[INFO] image submitter terminated") + }() + }) + + s.submitCh <- submitReq{idsFn: idsFn, TS: time.Now()} +} + +// ExtractPictures gets list of images from the doc html and convert from urls to ids, i.e. user/pic.png +func (s *Service) ExtractPictures(commentHTML string) (ids []string, err error) { + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML)) + if err != nil { + return nil, errors.Wrap(err, "can't create document") + } + result := []string{} + doc.Find("img").Each(func(i int, sl *goquery.Selection) { + if im, ok := sl.Attr("src"); ok { + if strings.Contains(im, s.ImageAPI) { + elems := strings.Split(im, "/") + if len(elems) >= 2 { + id := elems[len(elems)-2] + "/" + elems[len(elems)-1] + result = append(result, id) + } + } + } + }) + + return result, nil +} + +// Cleanup runs periodic cleanup with TTL. Blocking loop, should be called inside of goroutine by consumer +func (s *Service) Cleanup(ctx context.Context) { + log.Printf("[INFO] start pictures cleanup, staging ttl=%v", s.TTL) + + for { + select { + case <-ctx.Done(): + log.Printf("[INFO] cleanup terminated, %v", ctx.Err()) + return + case <-time.After(s.TTL / 2): + if err := s.Store.Cleanup(ctx, s.TTL); err != nil { + log.Printf("[WARN] failed to cleanup, %v", err) + } + } + } +} + +// Close flushes all in-progress submits and enforces waiting commits +func (s *Service) Close() { + log.Printf("[INFO] close image service ") + atomic.AddInt32(&s.term, 1) // enforce non-delayed commits for all ids left in submitCh + if s.submitCh != nil { + close(s.submitCh) + } + s.wg.Wait() +} diff --git a/backend/app/store/image/image_mock.go b/backend/app/store/image/image_mock.go new file mode 100644 index 00000000..0049e558 --- /dev/null +++ b/backend/app/store/image/image_mock.go @@ -0,0 +1,95 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: image.go + +// Package image is a generated GoMock package. +package image + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + io "io" + reflect "reflect" + time "time" +) + +// MockStore is a mock of Store interface +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder +} + +// MockStoreMockRecorder is the mock recorder for MockStore +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// Save mocks base method +func (m *MockStore) Save(fileName, userID string, r io.Reader) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", fileName, userID, r) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Save indicates an expected call of Save +func (mr *MockStoreMockRecorder) Save(fileName, userID, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockStore)(nil).Save), fileName, userID, r) +} + +// Commit mocks base method +func (m *MockStore) Commit(id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Commit", id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Commit indicates an expected call of Commit +func (mr *MockStoreMockRecorder) Commit(id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Commit", reflect.TypeOf((*MockStore)(nil).Commit), id) +} + +// Load mocks base method +func (m *MockStore) Load(id string) (io.ReadCloser, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", id) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Load indicates an expected call of Load +func (mr *MockStoreMockRecorder) Load(id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockStore)(nil).Load), id) +} + +// Cleanup mocks base method +func (m *MockStore) Cleanup(ctx context.Context, ttl time.Duration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Cleanup", ctx, ttl) + ret0, _ := ret[0].(error) + return ret0 +} + +// Cleanup indicates an expected call of Cleanup +func (mr *MockStoreMockRecorder) Cleanup(ctx, ttl interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cleanup", reflect.TypeOf((*MockStore)(nil).Cleanup), ctx, ttl) +} diff --git a/backend/app/store/image/image_test.go b/backend/app/store/image/image_test.go new file mode 100644 index 00000000..f26eec8a --- /dev/null +++ b/backend/app/store/image/image_test.go @@ -0,0 +1,77 @@ +package image + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestService_ExtractPictures(t *testing.T) { + svc := Service{ImageAPI: "/blah/"} + html := `blah foo + xyz

123

` + ids, err := svc.ExtractPictures(html) + require.NoError(t, err) + assert.Equal(t, 2, len(ids), "two images") + assert.Equal(t, "user1/pic1.png", ids[0]) + assert.Equal(t, "user2/pic3.png", ids[1]) +} + +func TestService_Cleanup(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + store := NewMockStore(ctrl) + store.EXPECT().Cleanup(gomock.Any(), gomock.Any()).Times(10) + + svc := Service{Store: store, TTL: 100 * time.Millisecond} + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*549) + defer cancel() + svc.Cleanup(ctx) +} + +func TestService_Submit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + store := NewMockStore(ctrl) + + store.EXPECT().Commit(gomock.Any()).Times(5) // all 5 should be committed + svc := Service{Store: store, 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) + time.Sleep(time.Millisecond * 500) +} + +func TestService_Close(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + store := NewMockStore(ctrl) + + store.EXPECT().Commit(gomock.Any()).Times(5) // all 5 should be committed + svc := Service{Store: store, 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) + svc.Close() +} + +func TestService_SubmitDelay(t *testing.T) { + ctrl := gomock.NewController(t) + defer func() { + ctrl.Finish() + }() + + store := NewMockStore(ctrl) + + store.EXPECT().Commit(gomock.Any()).Times(3) // first batch should be committed + svc := Service{Store: store, 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"} }) + svc.Submit(nil) +} diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index 4878dae9..fe30a28c 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -12,6 +12,7 @@ import ( multierror "github.com/hashicorp/go-multierror" cache "github.com/patrickmn/go-cache" "github.com/pkg/errors" + "github.com/umputun/remark/backend/app/store/image" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/admin" @@ -28,6 +29,7 @@ type DataStore struct { PositiveScore bool TitleExtractor *TitleExtractor RestrictedWordsMatcher *RestrictedWordsMatcher + ImageService *image.Service // granular locks scopedLocks struct { @@ -90,9 +92,32 @@ func (s *DataStore) Create(comment store.Comment) (commentID string, err error) comment.PostTitle = title }() + s.submitImages(comment) return s.Interface.Create(comment) } +// submitImages initiated delayed commit of all images from the comment uploaded to remark42 +func (s *DataStore) submitImages(comment store.Comment) { + + s.ImageService.Submit(func() []string { + c := comment + cc, err := s.Get(c.Locator, c.ID) // this can be called after last edit, we have to retrieve fresh comment + if err != nil { + log.Printf("[WARN] can't get comment's %s text for image extraction, %v", c.ID, err) + return nil + } + imgIds, err := s.ImageService.ExtractPictures(cc.Text) + if err != nil { + log.Printf("[WARN] can't get extract pictures from %s, %v", c.ID, err) + return nil + } + if len(imgIds) > 0 { + log.Printf("[DEBUG] image ids extracted from %s - %+v", c.ID, imgIds) + } + return imgIds + }) +} + // prepareNewComment sets new comment fields, hashing and sanitizing data func (s *DataStore) prepareNewComment(comment store.Comment) (store.Comment, error) { // fill ID and time if empty @@ -129,8 +154,8 @@ func (s *DataStore) SetPin(locator store.Locator, commentID string, status bool) // Vote for comment by id and locator func (s *DataStore) Vote(locator store.Locator, commentID string, userID string, val bool) (comment store.Comment, err error) { - cLock := s.getsScopedLocks(locator.URL) // get lock for URL scope - cLock.Lock() // prevents race on voting + cLock := s.getScopedLocks(locator.URL) // get lock for URL scope + cLock.Lock() // prevents race on voting defer cLock.Unlock() comment, err = s.Get(locator, commentID) @@ -455,8 +480,8 @@ func (s *DataStore) upsAndDowns(c store.Comment) (ups, downs int) { return ups, downs } -// getsScopedLocks pull lock from the map if found or create a new one -func (s *DataStore) getsScopedLocks(id string) (lock sync.Locker) { +// getScopedLocks pull lock from the map if found or create a new one +func (s *DataStore) getScopedLocks(id string) (lock sync.Locker) { s.scopedLocks.Do(func() { s.scopedLocks.locks = map[string]sync.Locker{} }) s.scopedLocks.Lock() diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index bd3e2541..42dcedd1 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -13,9 +13,12 @@ import ( "time" bolt "github.com/coreos/bbolt" + "github.com/go-pkgz/lgr" + "github.com/golang/mock/gomock" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/umputun/remark/backend/app/store/image" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/admin" @@ -25,7 +28,7 @@ import ( var testDb = "/tmp/test-remark.db" func TestService_CreateFromEmpty(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) ks := admin.NewStaticKeyStore("secret 123") b := DataStore{Interface: prepStoreEngine(t), AdminStore: ks} comment := store.Comment{ @@ -49,7 +52,7 @@ func TestService_CreateFromEmpty(t *testing.T) { } func TestService_CreateFromPartial(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) ks := admin.NewStaticKeyStore("secret 123") b := DataStore{Interface: prepStoreEngine(t), AdminStore: ks} comment := store.Comment{ @@ -76,7 +79,7 @@ func TestService_CreateFromPartial(t *testing.T) { } func TestService_CreateFromPartialWithTitle(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) ks := admin.NewStaticKeyStore("secret 123") b := DataStore{Interface: prepStoreEngine(t), AdminStore: ks, TitleExtractor: NewTitleExtractor(http.Client{Timeout: 5 * time.Second})} @@ -106,7 +109,7 @@ func TestService_CreateFromPartialWithTitle(t *testing.T) { } func TestService_SetTitle(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) var titleEnable int32 tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -114,11 +117,13 @@ func TestService_SetTitle(t *testing.T) { w.WriteHeader(404) } if r.URL.String() == "/post1" { - w.Write([]byte("post1 blah 123 2222")) + _, err := w.Write([]byte("post1 blah 123 2222")) + assert.NoError(t, err) return } if r.URL.String() == "/post2" { - w.Write([]byte("post2 blah 123 2222")) + _, err := w.Write([]byte("post2 blah 123 2222")) + assert.NoError(t, err) return } w.WriteHeader(404) @@ -158,7 +163,7 @@ func TestService_SetTitle(t *testing.T) { } func TestService_Vote(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1} comment := store.Comment{ @@ -204,7 +209,7 @@ func TestService_Vote(t *testing.T) { } func TestService_VoteLimit(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: 2} _, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", true) @@ -222,7 +227,7 @@ func TestService_VoteLimit(t *testing.T) { } func TestService_VotesDisabled(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: 0} _, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", true) @@ -230,7 +235,7 @@ func TestService_VotesDisabled(t *testing.T) { } func TestService_VoteAggressive(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1} comment := store.Comment{ @@ -259,7 +264,6 @@ func TestService_VoteAggressive(t *testing.T) { go func() { defer wg.Done() _, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", true) - }() } wg.Wait() @@ -291,7 +295,7 @@ func TestService_VoteAggressive(t *testing.T) { func TestService_VoteConcurrent(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1} comment := store.Comment{ @@ -308,10 +312,11 @@ func TestService_VoteConcurrent(t *testing.T) { var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) - i := i + ii := i go func() { defer wg.Done() - b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, fmt.Sprintf("user1-%d", i), true) + _, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, + fmt.Sprintf("user1-%d", ii), true) }() } wg.Wait() @@ -323,7 +328,7 @@ func TestService_VoteConcurrent(t *testing.T) { } func TestService_VotePositive(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1, PositiveScore: true} @@ -342,7 +347,7 @@ func TestService_VotePositive(t *testing.T) { } func TestService_VoteControversy(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1} c, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user2", false) @@ -392,7 +397,7 @@ func TestService_Controversy(t *testing.T) { } func TestService_Pin(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} res, err := b.Last("radio-t", 0) @@ -416,7 +421,7 @@ func TestService_Pin(t *testing.T) { } func TestService_EditComment(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} res, err := b.Last("radio-t", 0) @@ -443,7 +448,7 @@ func TestService_EditComment(t *testing.T) { } func TestService_DeleteComment(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} res, err := b.Last("radio-t", 0) @@ -462,7 +467,7 @@ func TestService_DeleteComment(t *testing.T) { } func TestService_EditCommentDurationFailed(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, AdminStore: admin.NewStaticKeyStore("secret 123")} res, err := b.Last("radio-t", 0) @@ -479,7 +484,7 @@ func TestService_EditCommentDurationFailed(t *testing.T) { } func TestService_EditCommentReplyFailed(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} res, err := b.Last("radio-t", 0) @@ -530,7 +535,7 @@ func TestService_ValidateComment(t *testing.T) { } func TestService_Counts(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) b := prepStoreEngine(t) // two comments for https://radio-t.com // add one more for https://radio-t.com/2 @@ -559,7 +564,7 @@ func TestService_Counts(t *testing.T) { } func TestService_GetMetas(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) // two comments for https://radio-t.com b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, AdminStore: admin.NewStaticKeyStore("secret 123")} @@ -590,7 +595,7 @@ func TestService_GetMetas(t *testing.T) { } func TestService_SetMetas(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) // two comments for https://radio-t.com b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, AdminStore: admin.NewStaticKeyStore("secret 123")} @@ -614,7 +619,7 @@ func TestService_SetMetas(t *testing.T) { } func TestService_IsAdmin(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) // two comments for https://radio-t.com b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, AdminStore: admin.NewStaticStore("secret 123", []string{"user2"}, "user@email.com")} @@ -624,7 +629,7 @@ func TestService_IsAdmin(t *testing.T) { } func TestService_HasReplies(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) // two comments for https://radio-t.com, no reply b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, @@ -654,7 +659,7 @@ func TestService_HasReplies(t *testing.T) { } func TestService_Find(t *testing.T) { - defer os.Remove(testDb) + defer teardown(t) // two comments for https://radio-t.com, no reply b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, @@ -687,9 +692,38 @@ func TestService_Find(t *testing.T) { assert.InDelta(t, 0, res[1].Controversy, 0.01) } +func TestService_submitImages(t *testing.T) { + defer teardown(t) + lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := image.NewMockStore(ctrl) + imgSvc := &image.Service{Store: mockStore, TTL: time.Millisecond * 50} + + mockStore.EXPECT().Commit(gomock.Any()).Times(2) + + // two comments for https://radio-t.com + b := DataStore{Interface: prepStoreEngine(t), EditDuration: 50 * time.Millisecond, + AdminStore: admin.NewStaticKeyStore("secret 123"), ImageService: imgSvc} + + c := store.Comment{ + ID: "id-22", + Text: `some text xx `, + Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local), + Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, + User: store.User{ID: "user1", Name: "user name"}, + } + _, err := b.Interface.Create(c) // create directly with engine, doesn't call submitImages + assert.NoError(t, err) + + b.submitImages(c) + time.Sleep(250 * time.Millisecond) +} + // makes new boltdb, put two records func prepStoreEngine(t *testing.T) engine.Interface { - os.Remove(testDb) + _ = os.Remove(testDb) boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/test-remark.db", SiteID: "radio-t"}) assert.Nil(t, err) @@ -717,3 +751,7 @@ func prepStoreEngine(t *testing.T) engine.Interface { return b } + +func teardown(_ *testing.T) { + _ = os.Remove(testDb) +} diff --git a/backend/app/store/service/title_test.go b/backend/app/store/service/title_test.go index 31b1eef7..aadd4072 100644 --- a/backend/app/store/service/title_test.go +++ b/backend/app/store/service/title_test.go @@ -44,7 +44,8 @@ func TestTitle_Get(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.String() == "/good" { atomic.AddInt32(&hits, 1) - w.Write([]byte("blah 123 2222")) + _, err := w.Write([]byte("blah 123 2222")) + assert.NoError(t, err) return } w.WriteHeader(404) @@ -58,9 +59,9 @@ func TestTitle_Get(t *testing.T) { require.NotNil(t, err) for i := 0; i < 100; i++ { - title, err := ex.Get(ts.URL + "/good") - require.Nil(t, err) - assert.Equal(t, "blah 123", title) + r, e := ex.Get(ts.URL + "/good") + require.Nil(t, e) + assert.Equal(t, "blah 123", r) } assert.Equal(t, int32(1), atomic.LoadInt32(&hits)) } @@ -75,7 +76,8 @@ func TestTitle_GetConcurrent(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.String(), "/good") { atomic.AddInt32(&hits, 1) - w.Write([]byte(fmt.Sprintf("blah 123 %s%s", r.URL.String(), body))) + _, err := w.Write([]byte(fmt.Sprintf("blah 123 %s%s", r.URL.String(), body))) + assert.NoError(t, err) return } w.WriteHeader(404) @@ -84,11 +86,11 @@ func TestTitle_GetConcurrent(t *testing.T) { g := syncs.NewSizedGroup(10) for i := 0; i < 100; i++ { - i := i + ii := i g.Go(func(_ context.Context) { - title, err := ex.Get(ts.URL + "/good/" + strconv.Itoa(i)) + title, err := ex.Get(ts.URL + "/good/" + strconv.Itoa(ii)) require.Nil(t, err) - assert.Equal(t, "blah 123 "+"/good/"+strconv.Itoa(i), title) + assert.Equal(t, "blah 123 "+"/good/"+strconv.Itoa(ii), title) }) } g.Wait() @@ -107,9 +109,9 @@ func TestTitle_GetFailed(t *testing.T) { require.NotNil(t, err) for i := 0; i < 100; i++ { - title, err := ex.Get(ts.URL + "/bad") - require.Nil(t, err) - assert.Equal(t, "", title) + r, e := ex.Get(ts.URL + "/bad") + require.Nil(t, e) + assert.Equal(t, "", r) } assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "hit once, errors cached") } diff --git a/backend/go.mod b/backend/go.mod index 6dc84b44..577d345e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,30 +10,30 @@ require ( github.com/didip/tollbooth v4.0.0+incompatible github.com/didip/tollbooth_chi v0.0.0-20170928041846-6ab5f3083f3d github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 - github.com/go-chi/chi v3.3.2+incompatible + github.com/go-chi/chi v4.0.2+incompatible github.com/go-chi/cors v1.0.0 github.com/go-chi/render v1.0.0 github.com/go-pkgz/auth v0.5.0 github.com/go-pkgz/lcw v0.2.0 - github.com/go-pkgz/lgr v0.4.0 + github.com/go-pkgz/lgr v0.6.2 github.com/go-pkgz/mongo v1.1.2 github.com/go-pkgz/repeater v1.1.1 github.com/go-pkgz/rest v1.4.0 github.com/go-pkgz/syncs v1.1.0 + github.com/golang/mock v1.2.0 github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c github.com/gorilla/feeds v1.1.0 - github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874 - github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc github.com/microcosm-cc/bluemonday v0.0.0-20171222152607-542fd4642604 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.8.1 github.com/rakyll/statik v0.1.3 - github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/stretchr/testify v1.3.0 golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 golang.org/x/net v0.0.0-20190107210223-45ffb0cd1ba0 - golang.org/x/time v0.0.0-20170927054726-6dc17368e09b // indirect + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect gopkg.in/russross/blackfriday.v2 v2.0.0 ) diff --git a/backend/go.sum b/backend/go.sum index 7a21bfa4..ee3962c0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -22,19 +22,23 @@ github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7a github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ= github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= +github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0= github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I= github.com/go-chi/render v1.0.0 h1:cLJlkaTB4xfx5rWhtoB0BSXsXVJKWFqv08Y3cR1bZKA= github.com/go-chi/render v1.0.0/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= -github.com/go-pkgz/auth v0.4.2 h1:WY3XzjUieUGxJSjXDU0rLrKt8RcPEaGdtLVRS7M56wU= -github.com/go-pkgz/auth v0.4.2/go.mod h1:CWtB8dHmOv+TfF3MUzKwk/YwTLepC2TaDL05A+pFVBM= github.com/go-pkgz/auth v0.5.0 h1:+wqppq35x83PchZNZ7SHHYLI/e8WeETFouujDLsklac= github.com/go-pkgz/auth v0.5.0/go.mod h1:CWtB8dHmOv+TfF3MUzKwk/YwTLepC2TaDL05A+pFVBM= github.com/go-pkgz/lcw v0.2.0 h1:aFoKUG8q0YybId+ThVRQpDMjjuSG4hkLL1EA2xUtruc= github.com/go-pkgz/lcw v0.2.0/go.mod h1:k+PY1CkCMTLXILtFoJOyK65Qqi9rkoTYunFH1vE/C0I= github.com/go-pkgz/lgr v0.2.2/go.mod h1:hBM1NM/SoYdlrykgdgJWGrZ/TM/XaZIjRbJfx7NkMm8= -github.com/go-pkgz/lgr v0.4.0 h1:s4490VXkaepbkMZBNZgr3rgfUg0G4nOLVa/Yp0hlwyc= -github.com/go-pkgz/lgr v0.4.0/go.mod h1:hBM1NM/SoYdlrykgdgJWGrZ/TM/XaZIjRbJfx7NkMm8= +github.com/go-pkgz/lgr v0.6.0 h1:Z9FRhfSyuASiF05iXRj+clTAAl8+1EfLR7SeyLIaGKg= +github.com/go-pkgz/lgr v0.6.0/go.mod h1:hBM1NM/SoYdlrykgdgJWGrZ/TM/XaZIjRbJfx7NkMm8= +github.com/go-pkgz/lgr v0.6.1 h1:poohUbv/iguoQ6bzJ5j/Ubl1VcsjU+rzTbbxsKbkGXk= +github.com/go-pkgz/lgr v0.6.1/go.mod h1:hBM1NM/SoYdlrykgdgJWGrZ/TM/XaZIjRbJfx7NkMm8= +github.com/go-pkgz/lgr v0.6.2 h1:Twf2YIe2J5tg7mKs+IkDDxrDF7GWlTCl/LzqELWjT5o= +github.com/go-pkgz/lgr v0.6.2/go.mod h1:hBM1NM/SoYdlrykgdgJWGrZ/TM/XaZIjRbJfx7NkMm8= github.com/go-pkgz/mongo v1.0.0/go.mod h1:R9si/F2aJsjz4MUxhzuppIHY8yLV3YCeuCpgcI50cu4= github.com/go-pkgz/mongo v1.1.2 h1:2Vqn3CWQJkkx4gxxDiQUitAW2FN/CH26lKHkipmpKcc= github.com/go-pkgz/mongo v1.1.2/go.mod h1:0NkWnzpiUxoL5fYZuttCtJrpC67oNDidfYxcdPqHTf0= @@ -45,20 +49,20 @@ github.com/go-pkgz/rest v1.4.0 h1:xNkdMjEL2rNZSHouWjFTH22ncaZ77fopm34RN+eXAwk= github.com/go-pkgz/rest v1.4.0/go.mod h1:COazNj35u3RXAgQNBr6neR599tYP3URiOpsu9p0rOtk= github.com/go-pkgz/syncs v1.1.0 h1:k+dTyUZs1JHsYzo2tuUNrnW0OCwuGuS6ozfXHVspjSY= github.com/go-pkgz/syncs v1.1.0/go.mod h1:bt9lxWRRJ9vOCMGc8Big8ttjYHLKP88ofj1y38UlaHE= +github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c h1:jWtZjFEUE/Bz0IeIhqCnyZ3HG6KRXSntXe4SjtuTH7c= github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk= github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= -github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce h1:prjrVgOk2Yg6w+PflHoszQNLTUh4kaByUcEWM/9uin4= -github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874 h1:em+tTnzgU7N22woTBMcSJAOW7tRHAkK597W+MD/CpK8= github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc h1:0L2sGkaj6MWuV1BfXsrLJ/+XA8RzKKVsYlLVXNkK1Lw= github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -79,9 +83,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rakyll/statik v0.1.3 h1:H/5HK3yNM7sDzOiMQtC2Q1N69hl+KxzomBBWus662LU= github.com/rakyll/statik v0.1.3/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -101,8 +106,8 @@ golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb h1:1w588/yEchbPNpa9sEvOcMZYb golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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/time v0.0.0-20170927054726-6dc17368e09b h1:3X+R0qq1+64izd8es+EttB6qcY+JDlVmAhpRXl7gpzU= -golang.org/x/time v0.0.0-20170927054726-6dc17368e09b/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= diff --git a/backend/vendor/github.com/go-chi/chi/.travis.yml b/backend/vendor/github.com/go-chi/chi/.travis.yml index a6d5de85..de3287e1 100644 --- a/backend/vendor/github.com/go-chi/chi/.travis.yml +++ b/backend/vendor/github.com/go-chi/chi/.travis.yml @@ -1,18 +1,18 @@ language: go go: - - 1.7.x - - 1.8.x - - 1.9.x - -install: - - go get -u golang.org/x/tools/cmd/goimports - - go get -u github.com/golang/lint/golint + - 1.10.x + - 1.11.x + - 1.12.x script: - go get -d -t ./... - go vet ./... - - golint ./... - go test ./... - > - goimports -d -e ./ | grep '.*' && { echo; echo "Aborting due to non-empty goimports output."; exit 1; } || : + go_version=$(go version); + if [ ${go_version:13:4} = "1.12" ]; then + go get -u golang.org/x/tools/cmd/goimports; + goimports -d -e ./ | grep '.*' && { echo; echo "Aborting due to non-empty goimports output."; exit 1; } || :; + fi + diff --git a/backend/vendor/github.com/go-chi/chi/CHANGELOG.md b/backend/vendor/github.com/go-chi/chi/CHANGELOG.md index 5f0ab254..d03e40c6 100644 --- a/backend/vendor/github.com/go-chi/chi/CHANGELOG.md +++ b/backend/vendor/github.com/go-chi/chi/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v4.0.0 (2019-01-10) + +- chi v4 requires Go 1.10.3+ (or Go 1.9.7+) - we have deprecated support for Go 1.7 and 1.8 +- router: respond with 404 on router with no routes (#362) +- router: additional check to ensure wildcard is at the end of a url pattern (#333) +- middleware: deprecate use of http.CloseNotifier (#347) +- middleware: fix RedirectSlashes to include query params on redirect (#334) +- History of changes: see https://github.com/go-chi/chi/compare/v3.3.4...v4.0.0 + + +## v3.3.4 (2019-01-07) + +- Minor middleware improvements. No changes to core library/router. Moving v3 into its +- own branch as a version of chi for Go 1.7, 1.8, 1.9, 1.10, 1.11 +- History of changes: see https://github.com/go-chi/chi/compare/v3.3.3...v3.3.4 + + +## v3.3.3 (2018-08-27) + +- Minor release +- See https://github.com/go-chi/chi/compare/v3.3.2...v3.3.3 + + ## v3.3.2 (2017-12-22) - Support to route trailing slashes on mounted sub-routers (#281) diff --git a/backend/vendor/github.com/go-chi/chi/README.md b/backend/vendor/github.com/go-chi/chi/README.md index c71a3a01..d36d4db5 100644 --- a/backend/vendor/github.com/go-chi/chi/README.md +++ b/backend/vendor/github.com/go-chi/chi/README.md @@ -3,7 +3,7 @@ [![GoDoc Widget]][GoDoc] [![Travis Widget]][Travis] -`chi` is a lightweight, idiomatic and composable router for building Go 1.7+ HTTP services. It's +`chi` is a lightweight, idiomatic and composable router for building Go HTTP services. It's especially good at helping you write large REST API services that are kept maintainable as your project grows and changes. `chi` is built on the new `context` package introduced in Go 1.7 to handle signaling, cancelation and request-scoped values across a handler chain. @@ -31,18 +31,12 @@ included some useful/optional subpackages: [middleware](/middleware), [render](h * **Context control** - built on new `context` package, providing value chaining, cancelations and timeouts * **Robust** - in production at Pressly, CloudFlare, Heroku, 99Designs, and many others (see [discussion](https://github.com/go-chi/chi/issues/91)) * **Doc generation** - `docgen` auto-generates routing documentation from your source to JSON or Markdown -* **No external dependencies** - plain ol' Go 1.7+ stdlib + net/http +* **No external dependencies** - plain ol' Go stdlib + net/http ## Examples -* [rest](https://github.com/go-chi/chi/blob/master/_examples/rest/main.go) - REST APIs made easy, productive and maintainable -* [logging](https://github.com/go-chi/chi/blob/master/_examples/logging/main.go) - Easy structured logging for any backend -* [limits](https://github.com/go-chi/chi/blob/master/_examples/limits/main.go) - Timeouts and Throttling -* [todos-resource](https://github.com/go-chi/chi/blob/master/_examples/todos-resource/main.go) - Struct routers/handlers, an example of another code layout style -* [versions](https://github.com/go-chi/chi/blob/master/_examples/versions/main.go) - Demo of `chi/render` subpkg -* [fileserver](https://github.com/go-chi/chi/blob/master/_examples/fileserver/main.go) - Easily serve static files -* [graceful](https://github.com/go-chi/chi/blob/master/_examples/graceful/main.go) - Graceful context signaling and server shutdown +See [_examples/](https://github.com/go-chi/chi/blob/master/_examples/) for a variety of examples. **As easy as:** @@ -70,8 +64,8 @@ Here is a little preview of how routing looks like with chi. Also take a look at in JSON ([routes.json](https://github.com/go-chi/chi/blob/master/_examples/rest/routes.json)) and in Markdown ([routes.md](https://github.com/go-chi/chi/blob/master/_examples/rest/routes.md)). -I highly recommend reading the source of the [examples](#examples) listed above, they will show you all the features -of chi and serve as a good form of documentation. +I highly recommend reading the source of the [examples](https://github.com/go-chi/chi/blob/master/_examples/) listed +above, they will show you all the features of chi and serve as a good form of documentation. ```go import ( @@ -232,7 +226,7 @@ type Router interface { } // Routes interface adds two methods for router traversal, which is also -// used by the `docgen` subpackage to generation documentation for Routers. +// used by the github.com/go-chi/docgen package to generate documentation for Routers. type Routes interface { // Routes returns the routing tree in an easily traversable structure. Routes() []Route @@ -261,7 +255,7 @@ friendly with any middleware in the community. This offers much better extensibi of packages and is at the heart of chi's purpose. Here is an example of a standard net/http middleware handler using the new request context -available in Go 1.7+. This middleware sets a hypothetical user identifier on the request +available in Go. This middleware sets a hypothetical user identifier on the request context and calls the next handler in the chain. ```go @@ -347,6 +341,7 @@ Please see https://github.com/go-chi for additional packages. | package | description | |:---------------------------------------------------|:------------------------------------------------------------- | [cors](https://github.com/go-chi/cors) | Cross-origin resource sharing (CORS) | +| [docgen](https://github.com/go-chi/docgen) | Print chi.Router routes at runtime | | [jwtauth](https://github.com/go-chi/jwtauth) | JWT authentication | | [hostrouter](https://github.com/go-chi/hostrouter) | Domain/host based request routing | | [httpcoala](https://github.com/go-chi/httpcoala) | HTTP request coalescer | @@ -374,33 +369,33 @@ and.. The benchmark suite: https://github.com/pkieltyka/go-http-routing-benchmark -Results as of Aug 31, 2017 on Go 1.9.0 +Results as of Jan 9, 2019 with Go 1.11.4 on Linux X1 Carbon laptop ```shell -BenchmarkChi_Param 3000000 607 ns/op 432 B/op 3 allocs/op -BenchmarkChi_Param5 2000000 935 ns/op 432 B/op 3 allocs/op -BenchmarkChi_Param20 1000000 1944 ns/op 432 B/op 3 allocs/op -BenchmarkChi_ParamWrite 2000000 664 ns/op 432 B/op 3 allocs/op -BenchmarkChi_GithubStatic 2000000 627 ns/op 432 B/op 3 allocs/op -BenchmarkChi_GithubParam 2000000 847 ns/op 432 B/op 3 allocs/op -BenchmarkChi_GithubAll 10000 175556 ns/op 87700 B/op 609 allocs/op -BenchmarkChi_GPlusStatic 3000000 566 ns/op 432 B/op 3 allocs/op -BenchmarkChi_GPlusParam 2000000 652 ns/op 432 B/op 3 allocs/op -BenchmarkChi_GPlus2Params 2000000 767 ns/op 432 B/op 3 allocs/op -BenchmarkChi_GPlusAll 200000 9794 ns/op 5616 B/op 39 allocs/op -BenchmarkChi_ParseStatic 3000000 590 ns/op 432 B/op 3 allocs/op -BenchmarkChi_ParseParam 2000000 656 ns/op 432 B/op 3 allocs/op -BenchmarkChi_Parse2Params 2000000 715 ns/op 432 B/op 3 allocs/op -BenchmarkChi_ParseAll 100000 18045 ns/op 11232 B/op 78 allocs/op -BenchmarkChi_StaticAll 10000 108871 ns/op 67827 B/op 471 allocs/op +BenchmarkChi_Param 3000000 475 ns/op 432 B/op 3 allocs/op +BenchmarkChi_Param5 2000000 696 ns/op 432 B/op 3 allocs/op +BenchmarkChi_Param20 1000000 1275 ns/op 432 B/op 3 allocs/op +BenchmarkChi_ParamWrite 3000000 505 ns/op 432 B/op 3 allocs/op +BenchmarkChi_GithubStatic 3000000 508 ns/op 432 B/op 3 allocs/op +BenchmarkChi_GithubParam 2000000 669 ns/op 432 B/op 3 allocs/op +BenchmarkChi_GithubAll 10000 134627 ns/op 87699 B/op 609 allocs/op +BenchmarkChi_GPlusStatic 3000000 402 ns/op 432 B/op 3 allocs/op +BenchmarkChi_GPlusParam 3000000 500 ns/op 432 B/op 3 allocs/op +BenchmarkChi_GPlus2Params 3000000 586 ns/op 432 B/op 3 allocs/op +BenchmarkChi_GPlusAll 200000 7237 ns/op 5616 B/op 39 allocs/op +BenchmarkChi_ParseStatic 3000000 408 ns/op 432 B/op 3 allocs/op +BenchmarkChi_ParseParam 3000000 488 ns/op 432 B/op 3 allocs/op +BenchmarkChi_Parse2Params 3000000 551 ns/op 432 B/op 3 allocs/op +BenchmarkChi_ParseAll 100000 13508 ns/op 11232 B/op 78 allocs/op +BenchmarkChi_StaticAll 20000 81933 ns/op 67826 B/op 471 allocs/op ``` -Comparison with other routers: https://gist.github.com/pkieltyka/c089f309abeb179cfc4deaa519956d8c +Comparison with other routers: https://gist.github.com/pkieltyka/123032f12052520aaccab752bd3e78cc NOTE: the allocs in the benchmark above are from the calls to http.Request's `WithContext(context.Context)` method that clones the http.Request, sets the `Context()` on the duplicated (alloc'd) request and returns it the new request object. This is just -how setting context on a request in Go 1.7+ works. +how setting context on a request in Go works. ## Credits diff --git a/backend/vendor/github.com/go-chi/chi/context.go b/backend/vendor/github.com/go-chi/chi/context.go index 30c5afed..229c9cbf 100644 --- a/backend/vendor/github.com/go-chi/chi/context.go +++ b/backend/vendor/github.com/go-chi/chi/context.go @@ -84,13 +84,13 @@ func (x *Context) URLParam(key string) string { // // For example, // -// func Instrument(next http.Handler) http.Handler { -// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// next.ServeHTTP(w, r) -// routePattern := chi.RouteContext(r.Context()).RoutePattern() -// measure(w, r, routePattern) -// }) -// } +// func Instrument(next http.Handler) http.Handler { +// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// next.ServeHTTP(w, r) +// routePattern := chi.RouteContext(r.Context()).RoutePattern() +// measure(w, r, routePattern) +// }) +// } func (x *Context) RoutePattern() string { routePattern := strings.Join(x.RoutePatterns, "") return strings.Replace(routePattern, "/*/", "/", -1) diff --git a/backend/vendor/github.com/go-chi/chi/middleware/closenotify17.go b/backend/vendor/github.com/go-chi/chi/middleware/closenotify17.go deleted file mode 100644 index 95802b13..00000000 --- a/backend/vendor/github.com/go-chi/chi/middleware/closenotify17.go +++ /dev/null @@ -1,42 +0,0 @@ -// +build go1.7,!go1.8 - -package middleware - -import ( - "context" - "net/http" -) - -// CloseNotify is a middleware that cancels ctx when the underlying -// connection has gone away. It can be used to cancel long operations -// on the server when the client disconnects before the response is ready. -// -// Note: this behaviour is standard in Go 1.8+, so the middleware does nothing -// on 1.8+ and exists just for backwards compatibility. -func CloseNotify(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - cn, ok := w.(http.CloseNotifier) - if !ok { - panic("chi/middleware: CloseNotify expects http.ResponseWriter to implement http.CloseNotifier interface") - } - closeNotifyCh := cn.CloseNotify() - - ctx, cancel := context.WithCancel(r.Context()) - defer cancel() - - go func() { - select { - case <-ctx.Done(): - return - case <-closeNotifyCh: - cancel() - return - } - }() - - r = r.WithContext(ctx) - next.ServeHTTP(w, r) - } - - return http.HandlerFunc(fn) -} diff --git a/backend/vendor/github.com/go-chi/chi/middleware/closenotify18.go b/backend/vendor/github.com/go-chi/chi/middleware/closenotify18.go deleted file mode 100644 index 4f0d73cc..00000000 --- a/backend/vendor/github.com/go-chi/chi/middleware/closenotify18.go +++ /dev/null @@ -1,17 +0,0 @@ -// +build go1.8 appengine - -package middleware - -import ( - "net/http" -) - -// CloseNotify is a middleware that cancels ctx when the underlying -// connection has gone away. It can be used to cancel long operations -// on the server when the client disconnects before the response is ready. -// -// Note: this behaviour is standard in Go 1.8+, so the middleware does nothing -// on 1.8+ and exists just for backwards compatibility. -func CloseNotify(next http.Handler) http.Handler { - return next -} diff --git a/backend/vendor/github.com/go-chi/chi/middleware/compress.go b/backend/vendor/github.com/go-chi/chi/middleware/compress.go index 006ad48f..d2876d4e 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/compress.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/compress.go @@ -11,24 +11,98 @@ import ( "strings" ) -type encoding int +var encoders = map[string]EncoderFunc{} -const ( - encodingNone encoding = iota - encodingGzip - encodingDeflate -) +var encodingPrecedence = []string{"br", "gzip", "deflate"} + +func init() { + // TODO: + // lzma: Opera. + // sdch: Chrome, Android. Gzip output + dictionary header. + // br: Brotli, see https://github.com/go-chi/chi/pull/326 + + // TODO: Exception for old MSIE browsers that can't handle non-HTML? + // https://zoompf.com/blog/2012/02/lose-the-wait-http-compression + SetEncoder("gzip", encoderGzip) + + // HTTP 1.1 "deflate" (RFC 2616) stands for DEFLATE data (RFC 1951) + // wrapped with zlib (RFC 1950). The zlib wrapper uses Adler-32 + // checksum compared to CRC-32 used in "gzip" and thus is faster. + // + // But.. some old browsers (MSIE, Safari 5.1) incorrectly expect + // raw DEFLATE data only, without the mentioned zlib wrapper. + // Because of this major confusion, most modern browsers try it + // both ways, first looking for zlib headers. + // Quote by Mark Adler: http://stackoverflow.com/a/9186091/385548 + // + // The list of browsers having problems is quite big, see: + // http://zoompf.com/blog/2012/02/lose-the-wait-http-compression + // https://web.archive.org/web/20120321182910/http://www.vervestudios.co/projects/compression-tests/results + // + // That's why we prefer gzip over deflate. It's just more reliable + // and not significantly slower than gzip. + SetEncoder("deflate", encoderDeflate) + + // NOTE: Not implemented, intentionally: + // case "compress": // LZW. Deprecated. + // case "bzip2": // Too slow on-the-fly. + // case "zopfli": // Too slow on-the-fly. + // case "xz": // Too slow on-the-fly. +} + +// An EncoderFunc is a function that wraps the provided ResponseWriter with a +// streaming compression algorithm and returns it. +// +// In case of failure, the function should return nil. +type EncoderFunc func(w http.ResponseWriter, level int) io.Writer + +// SetEncoder can be used to set the implementation of a compression algorithm. +// +// The encoding should be a standardised identifier. See: +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding +// +// For example, add the Brotli algortithm: +// +// import brotli_enc "gopkg.in/kothar/brotli-go.v0/enc" +// +// middleware.SetEncoder("br", func(w http.ResponseWriter, level int) io.Writer { +// params := brotli_enc.NewBrotliParams() +// params.SetQuality(level) +// return brotli_enc.NewBrotliWriter(params, w) +// }) +func SetEncoder(encoding string, fn EncoderFunc) { + encoding = strings.ToLower(encoding) + if encoding == "" { + panic("the encoding can not be empty") + } + if fn == nil { + panic("attempted to set a nil encoder function") + } + encoders[encoding] = fn + + var e string + for _, v := range encodingPrecedence { + if v == encoding { + e = v + } + } + + if e == "" { + encodingPrecedence = append([]string{e}, encodingPrecedence...) + } +} var defaultContentTypes = map[string]struct{}{ - "text/html": struct{}{}, - "text/css": struct{}{}, - "text/plain": struct{}{}, - "text/javascript": struct{}{}, - "application/javascript": struct{}{}, - "application/x-javascript": struct{}{}, - "application/json": struct{}{}, - "application/atom+xml": struct{}{}, - "application/rss+xml": struct{}{}, + "text/html": {}, + "text/css": {}, + "text/plain": {}, + "text/javascript": {}, + "application/javascript": {}, + "application/x-javascript": {}, + "application/json": {}, + "application/atom+xml": {}, + "application/rss+xml": {}, + "image/svg+xml": {}, } // DefaultCompress is a middleware that compresses response @@ -43,6 +117,11 @@ func DefaultCompress(next http.Handler) http.Handler { // body of a given content types to a data format based // on Accept-Encoding request header. It uses a given // compression level. +// +// NOTE: make sure to set the Content-Type header on your response +// otherwise this middleware will not compress the response body. For ex, in +// your handler you should set w.Header().Set("Content-Type", http.DetectContentType(yourBody)) +// or set it manually. func Compress(level int, types ...string) func(next http.Handler) http.Handler { contentTypes := defaultContentTypes if len(types) > 0 { @@ -54,159 +133,143 @@ func Compress(level int, types ...string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { - mcw := &maybeCompressResponseWriter{ + encoder, encoding := selectEncoder(r.Header) + + cw := &compressResponseWriter{ ResponseWriter: w, w: w, contentTypes: contentTypes, - encoding: selectEncoding(r.Header), + encoder: encoder, + encoding: encoding, level: level, } - defer mcw.Close() + defer cw.Close() - next.ServeHTTP(mcw, r) + next.ServeHTTP(cw, r) } return http.HandlerFunc(fn) } } -func selectEncoding(h http.Header) encoding { - enc := h.Get("Accept-Encoding") +func selectEncoder(h http.Header) (EncoderFunc, string) { + header := h.Get("Accept-Encoding") - switch { - // TODO: - // case "br": // Brotli, experimental. Firefox 2016, to-be-in Chromium. - // case "lzma": // Opera. - // case "sdch": // Chrome, Android. Gzip output + dictionary header. + // Parse the names of all accepted algorithms from the header. + accepted := strings.Split(strings.ToLower(header), ",") - case strings.Contains(enc, "gzip"): - // TODO: Exception for old MSIE browsers that can't handle non-HTML? - // https://zoompf.com/blog/2012/02/lose-the-wait-http-compression - return encodingGzip - - case strings.Contains(enc, "deflate"): - // HTTP 1.1 "deflate" (RFC 2616) stands for DEFLATE data (RFC 1951) - // wrapped with zlib (RFC 1950). The zlib wrapper uses Adler-32 - // checksum compared to CRC-32 used in "gzip" and thus is faster. - // - // But.. some old browsers (MSIE, Safari 5.1) incorrectly expect - // raw DEFLATE data only, without the mentioned zlib wrapper. - // Because of this major confusion, most modern browsers try it - // both ways, first looking for zlib headers. - // Quote by Mark Adler: http://stackoverflow.com/a/9186091/385548 - // - // The list of browsers having problems is quite big, see: - // http://zoompf.com/blog/2012/02/lose-the-wait-http-compression - // https://web.archive.org/web/20120321182910/http://www.vervestudios.co/projects/compression-tests/results - // - // That's why we prefer gzip over deflate. It's just more reliable - // and not significantly slower than gzip. - return encodingDeflate - - // NOTE: Not implemented, intentionally: - // case "compress": // LZW. Deprecated. - // case "bzip2": // Too slow on-the-fly. - // case "zopfli": // Too slow on-the-fly. - // case "xz": // Too slow on-the-fly. + // Find supported encoder by accepted list by precedence + for _, name := range encodingPrecedence { + if fn, ok := encoders[name]; ok && matchAcceptEncoding(accepted, name) { + return fn, name + } } - return encodingNone + // No encoder found to match the accepted encoding + return nil, "" } -type maybeCompressResponseWriter struct { +func matchAcceptEncoding(accepted []string, encoding string) bool { + for _, v := range accepted { + if strings.Index(v, encoding) >= 0 { + return true + } + } + return false +} + +type compressResponseWriter struct { http.ResponseWriter w io.Writer - encoding encoding + encoder EncoderFunc + encoding string contentTypes map[string]struct{} level int wroteHeader bool } -func (w *maybeCompressResponseWriter) WriteHeader(code int) { - if w.wroteHeader { +func (cw *compressResponseWriter) WriteHeader(code int) { + if cw.wroteHeader { return } - w.wroteHeader = true - defer w.ResponseWriter.WriteHeader(code) + cw.wroteHeader = true + defer cw.ResponseWriter.WriteHeader(code) // Already compressed data? - if w.ResponseWriter.Header().Get("Content-Encoding") != "" { + if cw.Header().Get("Content-Encoding") != "" { return } - // The content-length after compression is unknown - w.ResponseWriter.Header().Del("Content-Length") // Parse the first part of the Content-Type response header. contentType := "" - parts := strings.Split(w.ResponseWriter.Header().Get("Content-Type"), ";") + parts := strings.Split(cw.Header().Get("Content-Type"), ";") if len(parts) > 0 { contentType = parts[0] } // Is the content type compressable? - if _, ok := w.contentTypes[contentType]; !ok { + if _, ok := cw.contentTypes[contentType]; !ok { return } - // Select the compress writer. - switch w.encoding { - case encodingGzip: - gw, err := gzip.NewWriterLevel(w.ResponseWriter, w.level) - if err != nil { - w.w = w.ResponseWriter - return - } - w.w = gw - w.ResponseWriter.Header().Set("Content-Encoding", "gzip") + if cw.encoder != nil && cw.encoding != "" { + if wr := cw.encoder(cw.ResponseWriter, cw.level); wr != nil { + cw.w = wr + cw.Header().Set("Content-Encoding", cw.encoding) - case encodingDeflate: - dw, err := flate.NewWriter(w.ResponseWriter, w.level) - if err != nil { - w.w = w.ResponseWriter - return + // The content-length after compression is unknown + cw.Header().Del("Content-Length") } - w.w = dw - w.ResponseWriter.Header().Set("Content-Encoding", "deflate") } } -func (w *maybeCompressResponseWriter) Write(p []byte) (int, error) { - if !w.wroteHeader { - w.WriteHeader(http.StatusOK) +func (cw *compressResponseWriter) Write(p []byte) (int, error) { + if !cw.wroteHeader { + cw.WriteHeader(http.StatusOK) } - return w.w.Write(p) + return cw.w.Write(p) } -func (w *maybeCompressResponseWriter) Flush() { - if f, ok := w.w.(http.Flusher); ok { +func (cw *compressResponseWriter) Flush() { + if f, ok := cw.w.(http.Flusher); ok { f.Flush() } } -func (w *maybeCompressResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - if hj, ok := w.w.(http.Hijacker); ok { +func (cw *compressResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := cw.w.(http.Hijacker); ok { return hj.Hijack() } return nil, nil, errors.New("chi/middleware: http.Hijacker is unavailable on the writer") } -func (w *maybeCompressResponseWriter) CloseNotify() <-chan bool { - if cn, ok := w.w.(http.CloseNotifier); ok { - return cn.CloseNotify() +func (cw *compressResponseWriter) Push(target string, opts *http.PushOptions) error { + if ps, ok := cw.w.(http.Pusher); ok { + return ps.Push(target, opts) } - - // If the underlying writer does not implement http.CloseNotifier, return - // a channel that never receives a value. The semantics here is that the - // client never disconnnects before the request is processed by the - // http.Handler, which is close enough to the default behavior (when - // CloseNotify() is not even called). - return make(chan bool, 1) + return errors.New("chi/middleware: http.Pusher is unavailable on the writer") } -func (w *maybeCompressResponseWriter) Close() error { - if c, ok := w.w.(io.WriteCloser); ok { +func (cw *compressResponseWriter) Close() error { + if c, ok := cw.w.(io.WriteCloser); ok { return c.Close() } return errors.New("chi/middleware: io.WriteCloser is unavailable on the writer") } + +func encoderGzip(w http.ResponseWriter, level int) io.Writer { + gw, err := gzip.NewWriterLevel(w, level) + if err != nil { + return nil + } + return gw +} + +func encoderDeflate(w http.ResponseWriter, level int) io.Writer { + dw, err := flate.NewWriter(w, level) + if err != nil { + return nil + } + return dw +} diff --git a/backend/vendor/github.com/go-chi/chi/middleware/compress18.go b/backend/vendor/github.com/go-chi/chi/middleware/compress18.go deleted file mode 100644 index 0048f7d9..00000000 --- a/backend/vendor/github.com/go-chi/chi/middleware/compress18.go +++ /dev/null @@ -1,15 +0,0 @@ -// +build go1.8 appengine - -package middleware - -import ( - "errors" - "net/http" -) - -func (w *maybeCompressResponseWriter) Push(target string, opts *http.PushOptions) error { - if ps, ok := w.w.(http.Pusher); ok { - return ps.Push(target, opts) - } - return errors.New("chi/middleware: http.Pusher is unavailable on the writer") -} diff --git a/backend/vendor/github.com/go-chi/chi/middleware/content_type.go b/backend/vendor/github.com/go-chi/chi/middleware/content_type.go index 3a2dc20a..ee495787 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/content_type.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/content_type.go @@ -26,6 +26,12 @@ func AllowContentType(contentTypes ...string) func(next http.Handler) http.Handl return func(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength == 0 { + // skip check for empty content body + next.ServeHTTP(w, r) + return + } + s := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) if i := strings.Index(s, ";"); i > -1 { s = s[0:i] diff --git a/backend/vendor/github.com/go-chi/chi/middleware/logger.go b/backend/vendor/github.com/go-chi/chi/middleware/logger.go index 99fac03d..9f119d56 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/logger.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/logger.go @@ -16,7 +16,7 @@ var ( // DefaultLogger is called by the Logger middleware handler to log each request. // Its made a package-level variable so that it can be reconfigured for custom // logging configurations. - DefaultLogger = RequestLogger(&DefaultLogFormatter{Logger: log.New(os.Stdout, "", log.LstdFlags)}) + DefaultLogger = RequestLogger(&DefaultLogFormatter{Logger: log.New(os.Stdout, "", log.LstdFlags), NoColor: false}) ) // Logger is a middleware that logs the start and end of each request, along @@ -81,29 +81,32 @@ type LoggerInterface interface { // DefaultLogFormatter is a simple logger that implements a LogFormatter. type DefaultLogFormatter struct { - Logger LoggerInterface + Logger LoggerInterface + NoColor bool } // NewLogEntry creates a new LogEntry for the request. func (l *DefaultLogFormatter) NewLogEntry(r *http.Request) LogEntry { + useColor := !l.NoColor entry := &defaultLogEntry{ DefaultLogFormatter: l, request: r, buf: &bytes.Buffer{}, + useColor: useColor, } reqID := GetReqID(r.Context()) if reqID != "" { - cW(entry.buf, nYellow, "[%s] ", reqID) + cW(entry.buf, useColor, nYellow, "[%s] ", reqID) } - cW(entry.buf, nCyan, "\"") - cW(entry.buf, bMagenta, "%s ", r.Method) + cW(entry.buf, useColor, nCyan, "\"") + cW(entry.buf, useColor, bMagenta, "%s ", r.Method) scheme := "http" if r.TLS != nil { scheme = "https" } - cW(entry.buf, nCyan, "%s://%s%s %s\" ", scheme, r.Host, r.RequestURI, r.Proto) + cW(entry.buf, useColor, nCyan, "%s://%s%s %s\" ", scheme, r.Host, r.RequestURI, r.Proto) entry.buf.WriteString("from ") entry.buf.WriteString(r.RemoteAddr) @@ -114,33 +117,34 @@ func (l *DefaultLogFormatter) NewLogEntry(r *http.Request) LogEntry { type defaultLogEntry struct { *DefaultLogFormatter - request *http.Request - buf *bytes.Buffer + request *http.Request + buf *bytes.Buffer + useColor bool } func (l *defaultLogEntry) Write(status, bytes int, elapsed time.Duration) { switch { case status < 200: - cW(l.buf, bBlue, "%03d", status) + cW(l.buf, l.useColor, bBlue, "%03d", status) case status < 300: - cW(l.buf, bGreen, "%03d", status) + cW(l.buf, l.useColor, bGreen, "%03d", status) case status < 400: - cW(l.buf, bCyan, "%03d", status) + cW(l.buf, l.useColor, bCyan, "%03d", status) case status < 500: - cW(l.buf, bYellow, "%03d", status) + cW(l.buf, l.useColor, bYellow, "%03d", status) default: - cW(l.buf, bRed, "%03d", status) + cW(l.buf, l.useColor, bRed, "%03d", status) } - cW(l.buf, bBlue, " %dB", bytes) + cW(l.buf, l.useColor, bBlue, " %dB", bytes) l.buf.WriteString(" in ") if elapsed < 500*time.Millisecond { - cW(l.buf, nGreen, "%s", elapsed) + cW(l.buf, l.useColor, nGreen, "%s", elapsed) } else if elapsed < 5*time.Second { - cW(l.buf, nYellow, "%s", elapsed) + cW(l.buf, l.useColor, nYellow, "%s", elapsed) } else { - cW(l.buf, nRed, "%s", elapsed) + cW(l.buf, l.useColor, nRed, "%s", elapsed) } l.Logger.Print(l.buf.String()) @@ -148,7 +152,7 @@ func (l *defaultLogEntry) Write(status, bytes int, elapsed time.Duration) { func (l *defaultLogEntry) Panic(v interface{}, stack []byte) { panicEntry := l.NewLogEntry(l.request).(*defaultLogEntry) - cW(panicEntry.buf, bRed, "panic: %+v", v) + cW(panicEntry.buf, l.useColor, bRed, "panic: %+v", v) l.Logger.Print(panicEntry.buf.String()) l.Logger.Print(string(stack)) } diff --git a/backend/vendor/github.com/go-chi/chi/middleware/nocache.go b/backend/vendor/github.com/go-chi/chi/middleware/nocache.go index e5819ddd..2412829e 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/nocache.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/nocache.go @@ -14,7 +14,7 @@ var epoch = time.Unix(0, 0).Format(time.RFC1123) // Taken from https://github.com/mytrile/nocache var noCacheHeaders = map[string]string{ "Expires": epoch, - "Cache-Control": "no-cache, no-store, must-revalidate, private, max-age=0", + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", "Pragma": "no-cache", "X-Accel-Expires": "0", } diff --git a/backend/vendor/github.com/go-chi/chi/middleware/realip.go b/backend/vendor/github.com/go-chi/chi/middleware/realip.go index e9addbe3..146c2b0a 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/realip.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/realip.go @@ -22,7 +22,7 @@ var xRealIP = http.CanonicalHeaderKey("X-Real-IP") // You should only use this middleware if you can trust the headers passed to // you (in particular, the two headers this middleware uses), for example // because you have placed a reverse proxy like HAProxy or nginx in front of -// Goji. If your reverse proxies are configured to pass along arbitrary header +// chi. If your reverse proxies are configured to pass along arbitrary header // values from the client, or if you use this middleware without a reverse // proxy, malicious clients will be able to make you very sad (or, depending on // how you're using RemoteAddr, vulnerable to an attack of some sort). diff --git a/backend/vendor/github.com/go-chi/chi/middleware/request_id.go b/backend/vendor/github.com/go-chi/chi/middleware/request_id.go index 4574bde8..65b58f63 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/request_id.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/request_id.go @@ -17,7 +17,7 @@ import ( // Key to use when setting the request ID. type ctxKeyRequestID int -// RequestIDKey is the key that holds th unique request ID in a request context. +// RequestIDKey is the key that holds the unique request ID in a request context. const RequestIDKey ctxKeyRequestID = 0 var prefix string @@ -62,9 +62,13 @@ func init() { // counter. func RequestID(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { - myid := atomic.AddUint64(&reqid, 1) ctx := r.Context() - ctx = context.WithValue(ctx, RequestIDKey, fmt.Sprintf("%s-%06d", prefix, myid)) + requestID := r.Header.Get("X-Request-Id") + if requestID == "" { + myid := atomic.AddUint64(&reqid, 1) + requestID = fmt.Sprintf("%s-%06d", prefix, myid) + } + ctx = context.WithValue(ctx, RequestIDKey, requestID) next.ServeHTTP(w, r.WithContext(ctx)) } return http.HandlerFunc(fn) diff --git a/backend/vendor/github.com/go-chi/chi/middleware/strip.go b/backend/vendor/github.com/go-chi/chi/middleware/strip.go index 8f19766b..2b8b1842 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/strip.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/strip.go @@ -1,6 +1,7 @@ package middleware import ( + "fmt" "net/http" "github.com/go-chi/chi" @@ -28,6 +29,9 @@ func StripSlashes(next http.Handler) http.Handler { // RedirectSlashes is a middleware that will match request paths with a trailing // slash and redirect to the same path, less the trailing slash. +// +// NOTE: RedirectSlashes middleware is *incompatible* with http.FileServer, +// see https://github.com/go-chi/chi/issues/343 func RedirectSlashes(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { var path string @@ -38,7 +42,11 @@ func RedirectSlashes(next http.Handler) http.Handler { path = r.URL.Path } if len(path) > 1 && path[len(path)-1] == '/' { - path = path[:len(path)-1] + if r.URL.RawQuery != "" { + path = fmt.Sprintf("%s?%s", path[:len(path)-1], r.URL.RawQuery) + } else { + path = path[:len(path)-1] + } http.Redirect(w, r, path, 301) return } diff --git a/backend/vendor/github.com/go-chi/chi/middleware/terminal.go b/backend/vendor/github.com/go-chi/chi/middleware/terminal.go index 79930a25..a5d42410 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/terminal.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/terminal.go @@ -52,12 +52,12 @@ func init() { } // colorWrite -func cW(w io.Writer, color []byte, s string, args ...interface{}) { - if isTTY { +func cW(w io.Writer, useColor bool, color []byte, s string, args ...interface{}) { + if isTTY && useColor { w.Write(color) } fmt.Fprintf(w, s, args...) - if isTTY { + if isTTY && useColor { w.Write(reset) } } diff --git a/backend/vendor/github.com/go-chi/chi/middleware/timeout.go b/backend/vendor/github.com/go-chi/chi/middleware/timeout.go index 5cabf1f9..8e373536 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/timeout.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/timeout.go @@ -15,7 +15,8 @@ import ( // // ie. a route/handler may look like: // -// r.Get("/long", func(ctx context.Context, w http.ResponseWriter, r *http.Request) { +// r.Get("/long", func(w http.ResponseWriter, r *http.Request) { +// ctx := r.Context() // processTime := time.Duration(rand.Intn(4)+1) * time.Second // // select { diff --git a/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer.go b/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer.go index 5d1c286b..5e5594f8 100644 --- a/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer.go +++ b/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer.go @@ -10,6 +10,32 @@ import ( "net/http" ) +// NewWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to +// hook into various parts of the response process. +func NewWrapResponseWriter(w http.ResponseWriter, protoMajor int) WrapResponseWriter { + _, fl := w.(http.Flusher) + + bw := basicWriter{ResponseWriter: w} + + if protoMajor == 2 { + _, ps := w.(http.Pusher) + if fl && ps { + return &http2FancyWriter{bw} + } + } else { + _, hj := w.(http.Hijacker) + _, rf := w.(io.ReaderFrom) + if fl && hj && rf { + return &httpFancyWriter{bw} + } + } + if fl { + return &flushWriter{bw} + } + + return &bw +} + // WrapResponseWriter is a proxy around an http.ResponseWriter that allows you to hook // into various parts of the response process. type WrapResponseWriter interface { @@ -47,6 +73,7 @@ func (b *basicWriter) WriteHeader(code int) { b.ResponseWriter.WriteHeader(code) } } + func (b *basicWriter) Write(buf []byte) (int, error) { b.WriteHeader(http.StatusOK) n, err := b.ResponseWriter.Write(buf) @@ -60,20 +87,25 @@ func (b *basicWriter) Write(buf []byte) (int, error) { b.bytes += n return n, err } + func (b *basicWriter) maybeWriteHeader() { if !b.wroteHeader { b.WriteHeader(http.StatusOK) } } + func (b *basicWriter) Status() int { return b.code } + func (b *basicWriter) BytesWritten() int { return b.bytes } + func (b *basicWriter) Tee(w io.Writer) { b.tee = w } + func (b *basicWriter) Unwrap() http.ResponseWriter { return b.ResponseWriter } @@ -83,13 +115,15 @@ type flushWriter struct { } func (f *flushWriter) Flush() { + f.wroteHeader = true + fl := f.basicWriter.ResponseWriter.(http.Flusher) fl.Flush() } var _ http.Flusher = &flushWriter{} -// httpFancyWriter is a HTTP writer that additionally satisfies http.CloseNotifier, +// httpFancyWriter is a HTTP writer that additionally satisfies // http.Flusher, http.Hijacker, and io.ReaderFrom. It exists for the common case // of wrapping the http.ResponseWriter that package http gives you, in order to // make the proxied object support the full method set of the proxied object. @@ -97,18 +131,22 @@ type httpFancyWriter struct { basicWriter } -func (f *httpFancyWriter) CloseNotify() <-chan bool { - cn := f.basicWriter.ResponseWriter.(http.CloseNotifier) - return cn.CloseNotify() -} func (f *httpFancyWriter) Flush() { + f.wroteHeader = true + fl := f.basicWriter.ResponseWriter.(http.Flusher) fl.Flush() } + func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { hj := f.basicWriter.ResponseWriter.(http.Hijacker) return hj.Hijack() } + +func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error { + return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts) +} + func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { if f.basicWriter.tee != nil { n, err := io.Copy(&f.basicWriter, r) @@ -122,12 +160,12 @@ func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { return n, err } -var _ http.CloseNotifier = &httpFancyWriter{} var _ http.Flusher = &httpFancyWriter{} var _ http.Hijacker = &httpFancyWriter{} +var _ http.Pusher = &http2FancyWriter{} var _ io.ReaderFrom = &httpFancyWriter{} -// http2FancyWriter is a HTTP2 writer that additionally satisfies http.CloseNotifier, +// http2FancyWriter is a HTTP2 writer that additionally satisfies // http.Flusher, and io.ReaderFrom. It exists for the common case // of wrapping the http.ResponseWriter that package http gives you, in order to // make the proxied object support the full method set of the proxied object. @@ -135,14 +173,11 @@ type http2FancyWriter struct { basicWriter } -func (f *http2FancyWriter) CloseNotify() <-chan bool { - cn := f.basicWriter.ResponseWriter.(http.CloseNotifier) - return cn.CloseNotify() -} func (f *http2FancyWriter) Flush() { + f.wroteHeader = true + fl := f.basicWriter.ResponseWriter.(http.Flusher) fl.Flush() } -var _ http.CloseNotifier = &http2FancyWriter{} var _ http.Flusher = &http2FancyWriter{} diff --git a/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer17.go b/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer17.go deleted file mode 100644 index c60df608..00000000 --- a/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer17.go +++ /dev/null @@ -1,34 +0,0 @@ -// +build go1.7,!go1.8 - -package middleware - -import ( - "io" - "net/http" -) - -// NewWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to -// hook into various parts of the response process. -func NewWrapResponseWriter(w http.ResponseWriter, protoMajor int) WrapResponseWriter { - _, cn := w.(http.CloseNotifier) - _, fl := w.(http.Flusher) - - bw := basicWriter{ResponseWriter: w} - - if protoMajor == 2 { - if cn && fl { - return &http2FancyWriter{bw} - } - } else { - _, hj := w.(http.Hijacker) - _, rf := w.(io.ReaderFrom) - if cn && fl && hj && rf { - return &httpFancyWriter{bw} - } - } - if fl { - return &flushWriter{bw} - } - - return &bw -} diff --git a/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer18.go b/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer18.go deleted file mode 100644 index 115c2d4f..00000000 --- a/backend/vendor/github.com/go-chi/chi/middleware/wrap_writer18.go +++ /dev/null @@ -1,41 +0,0 @@ -// +build go1.8 appengine - -package middleware - -import ( - "io" - "net/http" -) - -// NewWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to -// hook into various parts of the response process. -func NewWrapResponseWriter(w http.ResponseWriter, protoMajor int) WrapResponseWriter { - _, cn := w.(http.CloseNotifier) - _, fl := w.(http.Flusher) - - bw := basicWriter{ResponseWriter: w} - - if protoMajor == 2 { - _, ps := w.(http.Pusher) - if cn && fl && ps { - return &http2FancyWriter{bw} - } - } else { - _, hj := w.(http.Hijacker) - _, rf := w.(io.ReaderFrom) - if cn && fl && hj && rf { - return &httpFancyWriter{bw} - } - } - if fl { - return &flushWriter{bw} - } - - return &bw -} - -func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error { - return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts) -} - -var _ http.Pusher = &http2FancyWriter{} diff --git a/backend/vendor/github.com/go-chi/chi/mux.go b/backend/vendor/github.com/go-chi/chi/mux.go index 84a2424a..e553287e 100644 --- a/backend/vendor/github.com/go-chi/chi/mux.go +++ b/backend/vendor/github.com/go-chi/chi/mux.go @@ -60,7 +60,8 @@ func NewMux() *Mux { func (mx *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Ensure the mux has some routes defined on the mux if mx.handler == nil { - panic("chi: attempting to route to a mux with no handlers.") + mx.NotFoundHandler().ServeHTTP(w, r) + return } // Check if a routing context already exists from a parent router. diff --git a/backend/vendor/github.com/go-chi/chi/tree.go b/backend/vendor/github.com/go-chi/chi/tree.go index a55d7f14..8a044f3e 100644 --- a/backend/vendor/github.com/go-chi/chi/tree.go +++ b/backend/vendor/github.com/go-chi/chi/tree.go @@ -33,15 +33,15 @@ var mALL = mCONNECT | mDELETE | mGET | mHEAD | mOPTIONS | mPATCH | mPOST | mPUT | mTRACE var methodMap = map[string]methodTyp{ - "CONNECT": mCONNECT, - "DELETE": mDELETE, - "GET": mGET, - "HEAD": mHEAD, - "OPTIONS": mOPTIONS, - "PATCH": mPATCH, - "POST": mPOST, - "PUT": mPUT, - "TRACE": mTRACE, + http.MethodConnect: mCONNECT, + http.MethodDelete: mDELETE, + http.MethodGet: mGET, + http.MethodHead: mHEAD, + http.MethodOptions: mOPTIONS, + http.MethodPatch: mPATCH, + http.MethodPost: mPOST, + http.MethodPut: mPUT, + http.MethodTrace: mTRACE, } // RegisterMethod adds support for custom HTTP method handlers, available @@ -706,7 +706,9 @@ func patNextSegment(pattern string) (nodeTyp, string, string, byte, int, int) { } // Wildcard pattern as finale - // TODO: should we panic if there is stuff after the * ??? + if ws < len(pattern)-1 { + panic("chi: wildcard '*' must be the last value in a route. trim trailing text or use a '{param}' instead") + } return ntCatchAll, "*", "", 0, ws, len(pattern) } diff --git a/backend/vendor/github.com/go-pkgz/lgr/.golangci.yml b/backend/vendor/github.com/go-pkgz/lgr/.golangci.yml new file mode 100644 index 00000000..989af86e --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/lgr/.golangci.yml @@ -0,0 +1,60 @@ +linters-settings: + govet: + check-shadowing: true + golint: + min-confidence: 0 + gocyclo: + min-complexity: 15 + maligned: + suggest-new: true + dupl: + threshold: 100 + goconst: + min-len: 2 + min-occurrences: 2 + misspell: + locale: US + lll: + line-length: 140 + gocritic: + enabled-tags: + - performance + - style + - experimental + disabled-checks: + - wrapperFunc + +linters: + disable-all: true + enable: + - megacheck + - govet + - unconvert + - megacheck + - structcheck + - gas + - gocyclo + - dupl + - misspell + - unparam + - varcheck + - deadcode + - typecheck + - ineffassign + - varcheck + fast: false + + +run: +# modules-download-mode: vendor + skip-dirs: + - vendor + +issues: + exclude-rules: + - text: "weak cryptographic primitive" + linters: + - gosec + +service: + golangci-lint-version: 1.16.x \ No newline at end of file diff --git a/backend/vendor/github.com/go-pkgz/lgr/.travis.yml b/backend/vendor/github.com/go-pkgz/lgr/.travis.yml index 4e4d2ade..189a75c8 100644 --- a/backend/vendor/github.com/go-pkgz/lgr/.travis.yml +++ b/backend/vendor/github.com/go-pkgz/lgr/.travis.yml @@ -13,7 +13,6 @@ before_install: script: - GO111MODULE=on go get ./... - - GO111MODULE=on go mod vendor - - GO111MODULE=on go test -v -mod=vendor -covermode=count -coverprofile=profile.cov ./... || travis_terminate 1; - - golangci-lint run || travis_terminate 1; + - GO111MODULE=on go test -v -covermode=count -coverprofile=profile.cov ./... || travis_terminate 1; + - golangci-lint run || travis_terminate 1; - $GOPATH/bin/goveralls -coverprofile=profile.cov -service=travis-ci diff --git a/backend/vendor/github.com/go-pkgz/lgr/README.md b/backend/vendor/github.com/go-pkgz/lgr/README.md index 4ac2c61e..33559091 100644 --- a/backend/vendor/github.com/go-pkgz/lgr/README.md +++ b/backend/vendor/github.com/go-pkgz/lgr/README.md @@ -7,14 +7,14 @@ ## usage ```go - l := lgr.New(lgr.Debug, lgr.CallerFile) // allow debug and caller file info + l := lgr.New(lgr.Msec, lgr.Debug, lgr.CallerFile, lgr.CallerFunc) // allow debug and caller info, timestamp with milliseconds l.Logf("INFO some important message, %v", err) l.Logf("DEBUG some less important message, %v", err) ``` output looks like this: ``` -2018/01/07 13:02:34.000 INFO {svc/handler.go:101 h.MyFunc1} some important message, can't open file` +2018/01/07 13:02:34.000 INFO {svc/handler.go:101 h.MyFunc1} some important message, can't open file myfile.xyz 2018/01/07 13:02:34.015 DEBUG {svc/handler.go:155 h.MyFunc2} some less important message, file is too small` ``` @@ -25,34 +25,69 @@ _Without `lgr.Caller*` it will drop `{caller}` part_ ### interfaces and default loggers - `lgr` package provides a single interface `lgr.L` with a single method `Logf(format string, args ...interface{})`. Function wrapper `lgr.Func` allows to make `lgr.L` from a function directly. -- Default logger functionality can be used without `lgr.New`, but just `lgr.Printf` +- Default logger functionality can be used without `lgr.New` (see "global logger") - Two predefined loggers available: `lgr.NoOp` (do-nothing logger) and `lgr.Std` (passing directly to stdlib log) ### options `lgr.New` call accepts functional options: -- `lgr.Debug` - turn debug mode on to allow messages with "DEBUG" level (filtered overwise) +- `lgr.Debug` - turn debug mode on to allow messages with "DEBUG" level (filtered otherwise) +- `lgr.Out(io.Writer)` - sets the output writer, default `os.Stdout` +- `lgr.Err(io.Writer)` - sets the error writer, default `os.Stderr` - `lgr.CallerFile` - adds the caller file info - `lgr.CallerFunc` - adds the caller function info - `lgr.CallerPkg` - adds the caller package - `lgr.LevelBraces` - wraps levels with "[" and "]" - `lgr.Msec` - adds milliseconds to timestamp -- `lgr.Out(io.Writer)` - sets the output writer, default `os.Stdout` -- `lgr.Err(io.Writer)` - sets the error writer, default `os.Stderr` +- `lgr.Format` - sets custom template, overwrite all other formatting modifiers. +example: `l := lgr.New(lgr.Debug, lgr.Msec)` + +#### formatting templates: + +Several predefined templates provided and can be passed directly to `lgr.Format`, i.e. `lgr.Format(lgr.WithMsec)` + +``` + Short = `{{.DT.Format "2006/01/02 15:04:05"}} {{.Level}} {{.Message}}` + WithMsec = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} {{.Message}}` + WithPkg = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerPkg}}) {{.Message}}` + ShortDebug = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerFile}}:{{.CallerLine}}) {{.Message}}` + FuncDebug = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerFunc}}) {{.Message}}` + FullDebug = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerFile}}:{{.CallerLine}} {{.CallerFunc}}) {{.Message}}` +``` + +User can make a custom template and pass it directly to `lgr.Format`. For example: + +```go + lgr.Format(`{{.Level}} - {{.DT.Format "2006-01-02T15:04:05Z07:00"}} - {{.CallerPkg}} - {{.Message}}`) +``` + +_Note: formatter (predefined or custom) adds measurable overhead - the cost will depend on the version of Go, but is between 30 + and 50% in recent tests with 1.12. You can validate this in your environment via benchmarks: `go test -bench=. -run=Bench`_ + ### levels -`lgr.Logf` recognizes prefixes like "INFO" or "[INFO]" as levels. The full list of supported levels - "DEBUG", "INFO", "WARN", "ERROR", "PANIC" and "FATAL" +`lgr.Logf` recognizes prefixes like "INFO" or "[INFO]" as levels. The full list of supported levels - "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "PANIC" and "FATAL" -- `DEBUG` will be filtered unless `lgr.Debug` option defined +- `TRACE` will be filtered unless `lgr.Trace` option defined +- `DEBUG` will be filtered unless `lgr.Debug` or `lgr.Trace` options defined - `INFO` and `WARN` don't have any special behavior attached - `ERROR` sends messages to both out and err writers -- `PANIC` and `FATAL` send messages to both out and err writers. In addition sends dump of callers and runtime info to err only, and calls `os.Exit(1)`. +- `FATAL` and send messages to both out and err writers and exit(1) +- `PANIC` does the same as `FATAL` but in addition sends dump of callers and runtime info to err. + +### adaptors + +`lgr` logger can be converted to `io.Writer` or `*log.Logger` + +- `lgr.ToWriter(l lgr.L, level string) io.Writer` - makes io.Writer forwarding write ops to underlying `lgr.L` +- `lgr.ToStdLogger(l lgr.L, level string) *log.Logger` - makes standard logger on top of `lgr.L` + +_`level` parameter is optional, if defined (non-empty) will enforce the level._ ### global logger Users **should avoid** global logger and pass the concrete logger as a dependency. However, in some cases a global logger may be needed, for example migration from stdlib `log` to `lgr`. For such cases `log "github.com/go-pkgz/lgr"` can be imported instead of `log` package. Global logger provides `lgr.Printf`, `lgr.Print` and `lgr.Fatalf` functions. User can customize the logger by calling `lgr.Setup(options ...)`. The instance of this logger can be retrieved with `lgr.Default()` - diff --git a/backend/vendor/github.com/go-pkgz/lgr/adaptor.go b/backend/vendor/github.com/go-pkgz/lgr/adaptor.go new file mode 100644 index 00000000..2627325d --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/lgr/adaptor.go @@ -0,0 +1,30 @@ +package lgr + +import ( + "log" + "strings" +) + +// Writer holds lgr.L and wraps with io.Writer interface +type Writer struct { + L + level string // if defined added to each message +} + +// Write to lgr.L +func (w *Writer) Write(p []byte) (n int, err error) { + w.Logf(w.level + string(p)) + return len(p), nil +} + +// ToWriter makes io.Writer for given lgr.L with optional level +func ToWriter(l L, level string) *Writer { + if level != "" && !strings.HasSuffix(level, " ") { + level += " " + } + return &Writer{l, level} +} + +func ToStdLogger(l L, level string) *log.Logger { + return log.New(ToWriter(l, level), "", 0) +} diff --git a/backend/vendor/github.com/go-pkgz/lgr/interface.go b/backend/vendor/github.com/go-pkgz/lgr/interface.go index 80259cb3..46b961aa 100644 --- a/backend/vendor/github.com/go-pkgz/lgr/interface.go +++ b/backend/vendor/github.com/go-pkgz/lgr/interface.go @@ -2,7 +2,6 @@ package lgr import ( stdlog "log" - "os" ) var def = New() // default logger doesn't allow DEBUG and doesn't add caller info @@ -15,7 +14,7 @@ type L interface { // Func type is an adapter to allow the use of ordinary functions as Logger. type Func func(format string, args ...interface{}) -// Logf calls f(id) +// Logf calls f(format, args...) func (f Func) Logf(format string, args ...interface{}) { f(format, args...) } // NoOp logger @@ -26,24 +25,23 @@ var Std = Func(func(format string, args ...interface{}) { stdlog.Printf(format, // Printf simplifies replacement of std logger func Printf(format string, args ...interface{}) { - def.Logf(format, args...) + def.logf(format, args...) } // Print simplifies replacement of std logger func Print(line string) { - def.Logf(line) + def.logf(line) } // Fatalf simplifies replacement of std logger func Fatalf(format string, args ...interface{}) { - def.Logf(format, args...) - os.Exit(1) + def.logf(format, args...) + def.fatal() } // Setup default logger with options func Setup(opts ...Option) { def = New(opts...) - def.callerSkip = 2 } // Default returns pre-constructed def logger (debug off, callers disabled) diff --git a/backend/vendor/github.com/go-pkgz/lgr/logger.go b/backend/vendor/github.com/go-pkgz/lgr/logger.go index d3346fd7..2dd86885 100644 --- a/backend/vendor/github.com/go-pkgz/lgr/logger.go +++ b/backend/vendor/github.com/go-pkgz/lgr/logger.go @@ -1,156 +1,296 @@ +// Package lgr provides a simple logger with some extras. Primary way to log is Logf method. +// The logger's output can be customized in 2 ways: +// - by setting individual formatting flags, i.e. lgr.New(lgr.Msec, lgr.CallerFunc) +// - by passing formatting template, i.e. lgr.New(lgr.Format(lgr.Short)) +// Leveled output works for messages based on text prefix, i.e. Logf("INFO some message") means INFO level. +// Debug and trace levels can be filtered based on lgr.Trace and lgr.Debug options. +// ERROR, FATAL and PANIC levels send to err as well. FATAL terminate caller application with os.Exit(1) +// and PANIC also prints stack trace. + package lgr import ( + "bytes" "fmt" "io" "os" "path" "runtime" + "strconv" "strings" "sync" + "text/template" "time" ) -var levels = []string{"DEBUG", "INFO", "WARN", "ERROR", "PANIC", "FATAL"} +var levels = []string{"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "PANIC", "FATAL"} + +const ( + Short = `{{.DT.Format "2006/01/02 15:04:05"}} {{.Level}} {{.Message}}` + WithMsec = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} {{.Message}}` + WithPkg = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerPkg}}) {{.Message}}` + ShortDebug = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerFile}}:{{.CallerLine}}) {{.Message}}` + FuncDebug = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerFunc}}) {{.Message}}` + FullDebug = `{{.DT.Format "2006/01/02 15:04:05.000"}} {{.Level}} ({{.CallerFile}}:{{.CallerLine}} {{.CallerFunc}}) {{.Message}}` +) // Logger provided simple logger with basic support of levels. Thread safe type Logger struct { - stdout, stderr io.Writer - dbg bool - lock sync.Mutex - callerFile bool - callerFunc bool - callerPkg bool - callerSkip int - ignoredPkgCallers []string + // set with Option calls + stdout, stderr io.Writer // destination writes for out and err + dbg bool // allows reporting for DEBUG level + trace bool // allows reporting for TRACE and DEBUG levels + callerFile bool // reports caller file with line number, i.e. foo/bar.go:89 + callerFunc bool // reports caller function name, i.e. bar.myFunc + callerPkg bool // reports caller package name + levelBraces bool // encloses level with [], i.e. [INFO] + callerDepth int // how many stack frames to skip, relative to the real (reported) frame + format string // layout template - now nowFn - fatal panicFn - levelBraces bool - msec bool + // internal use + now nowFn + fatal panicFn + msec bool + lock sync.Mutex + callerOn bool + levelBracesOn bool + templ *template.Template } +// can be redefined internally for testing type nowFn func() time.Time type panicFn func() -// New makes new leveled logger. Accepts dbg flag turing on info about the caller and allowing DEBUG messages/ -// Two writers can be passed optionally - first for out and second for err +// layout holds all parts to construct the final message with template or with individual flags +type layout struct { + DT time.Time + Level string + Message string + CallerPkg string + CallerFile string + CallerFunc string + CallerLine int +} + +// New makes new leveled logger. By default writes to stdout/stderr. +// default format: 2018/01/07 13:02:34.123 DEBUG some message 123 func New(options ...Option) *Logger { + res := Logger{ - now: time.Now, - fatal: func() { os.Exit(1) }, - stdout: os.Stdout, - stderr: os.Stderr, - callerSkip: 1, + now: time.Now, + fatal: func() { os.Exit(1) }, + stdout: os.Stdout, + stderr: os.Stderr, + callerDepth: 0, } for _, opt := range options { opt(&res) } + + if res.format != "" { + // formatter defined + var err error + res.templ, err = template.New("lgr").Parse(res.format) + if err != nil { + fmt.Printf("invalid template %s, error %v. switched to %s\n", res.format, err, Short) + res.format = Short + res.templ = template.Must(template.New("lgrDefault").Parse(Short)) + } + + buf := bytes.Buffer{} + if err = res.templ.Execute(&buf, layout{}); err != nil { + fmt.Printf("failed to execute template %s, error %v. switched to %s\n", res.format, err, Short) + res.format = Short + res.templ = template.Must(template.New("lgrDefault").Parse(Short)) + } + } + + // set *On flags once for optimization on multiple Logf calls + res.callerOn = strings.Contains(res.format, "{{.Caller") || res.callerFile || res.callerFunc || res.callerPkg + res.levelBracesOn = strings.Contains(res.format, "[{{.Level}}]") || res.levelBraces + return &res } // Logf implements L interface to output with printf style. -// Each line prefixed with ts, level and optionally (dbg mode only) by caller info. +// DEBUG and TRACE filtered out by dbg and trace flags. // ERROR and FATAL also send the same line to err writer. -// FATAL adds runtime stack and os.exit(1), like panic. +// FATAL and PANIC adds runtime stack and os.exit(1), like panic. func (l *Logger) Logf(format string, args ...interface{}) { + // to align call depth between (*Logger).Logf() and, for example, Printf() + l.logf(format, args...) +} - // format timestamp with or without msecs - ts := func() (res string) { - if l.msec { - return l.now().Format("2006/01/02 15:04:05.000") - } - return l.now().Format("2006/01/02 15:04:05") - } +func (l *Logger) logf(format string, args ...interface{}) { lv, msg := l.extractLevel(fmt.Sprintf(format, args...)) if lv == "DEBUG" && !l.dbg { return } - var bld strings.Builder - bld.WriteString(ts()) - bld.WriteString(l.formatLevel(lv)) - bld.WriteString(" ") - - if l.callerFile || l.callerFunc || l.callerPkg { - if pc, file, line, ok := runtime.Caller(l.callerSkip); ok { - - funcName, fileInfo := "", "" - - if l.callerFunc { - funcNameElems := strings.Split(runtime.FuncForPC(pc).Name(), "/") - funcName = funcNameElems[len(funcNameElems)-1] - } - - if l.callerFile { - fnameElems := strings.Split(file, "/") - fileInfo = fmt.Sprintf("%s:%d", strings.Join(fnameElems[len(fnameElems)-2:], "/"), line) - if l.callerFunc { - fileInfo += " " - } - } - // callerPkg only if no other callers - if l.callerPkg && !l.callerFile && !l.callerFunc { - file = l.ignoreCaller(file) - _, fileInfo = path.Split(path.Dir(file)) - if l.callerFunc { - fileInfo += " " - } - } - srcFileInfo := fmt.Sprintf("{%s%s} ", fileInfo, funcName) - bld.WriteString(srcFileInfo) - } + if lv == "TRACE" && !l.trace { + return } - bld.WriteString(msg) //nolint - bld.WriteString("\n") //nolint + var ci callerInfo + if l.callerOn { // optimization to avoid expensive caller evaluation if caller info not in the template + ci = l.reportCaller(l.callerDepth) + } + + elems := layout{ + DT: l.now(), + Level: l.formatLevel(lv), + Message: strings.TrimSuffix(msg, "\n"), // output adds EOL, trim from the message if passed + CallerFunc: ci.FuncName, + CallerFile: ci.File, + CallerPkg: ci.Pkg, + CallerLine: ci.Line, + } + + var data []byte + if l.format == "" { + data = []byte(l.formatWithOptions(elems)) + } else { + buf := bytes.Buffer{} + err := l.templ.Execute(&buf, elems) // once constructed, a template may be executed safely in parallel. + if err != nil { + fmt.Printf("failed to execute template, %v\n", err) // should never happen + } + data = buf.Bytes() + } + data = append(data, '\n') + + if l.levelBracesOn { // rearrange space in short levels + data = bytes.Replace(data, []byte("[WARN ]"), []byte("[WARN] "), 1) + data = bytes.Replace(data, []byte("[INFO ]"), []byte("[INFO] "), 1) + } l.lock.Lock() - msgb := []byte(bld.String()) - l.stdout.Write(msgb) //nolint + _, _ = l.stdout.Write(data) + // write to err as well for high levels, exit(1) on fatal and panic and dump stack on panic level switch lv { - case "PANIC", "FATAL": - l.stderr.Write(msgb) //nolint - bld.WriteString("\n") //nolint - l.stderr.Write(getDump()) //nolint - l.fatal() case "ERROR": - l.stderr.Write(msgb) //nolint + _, _ = l.stderr.Write(data) + case "FATAL": + _, _ = l.stderr.Write(data) + l.fatal() + case "PANIC": + _, _ = l.stderr.Write(data) + _, _ = l.stderr.Write(getDump()) + l.fatal() } l.lock.Unlock() } -func (l *Logger) ignoreCaller(p string) string { - for _, s := range l.ignoredPkgCallers { - if strings.Contains(p, "/"+s+"/") { - return strings.Replace(p, "/"+s, "", 1) - } - } - return p +type callerInfo struct { + File string + Line int + FuncName string + Pkg string } -func (l *Logger) formatLevel(lv string) string { +// calldepth 0 identifying the caller of reportCaller() +func (l *Logger) reportCaller(calldepth int) (res callerInfo) { - brace := func(b string) string { - if l.levelBraces { - return b + // caller gets file, line number abd function name via runtime.Callers + // file looks like /go/src/github.com/go-pkgz/lgr/logger.go + // file is an empty string if not known. + // funcName looks like: + // main.Test + // foo/bar.Test + // foo/bar.Test.func1 + // foo/bar.(*Bar).Test + // foo/bar.glob..func1 + // funcName is an empty string if not known. + // line is a zero if not known. + caller := func(calldepth int) (file string, line int, funcName string) { + pcs := make([]uintptr, 1) + n := runtime.Callers(calldepth, pcs) + if n != 1 { + return "", 0, "" } - return "" + + frame, _ := runtime.CallersFrames(pcs).Next() + + return frame.File, frame.Line, frame.Function } - if lv == "" { - return "" + // add 5 to adjust stack level because it was called from 3 nested functions added by lgr, i.e. caller, + // reportCaller and logf, plus 2 frames by runtime + filePath, line, funcName := caller(calldepth + 2 + 3) + if (filePath == "") || (line <= 0) || (funcName == "") { + return callerInfo{} } + _, pkgInfo := path.Split(path.Dir(filePath)) + res.Pkg = pkgInfo + + res.File = filePath + if pathElems := strings.Split(filePath, "/"); len(pathElems) > 2 { + res.File = strings.Join(pathElems[len(pathElems)-2:], "/") + } + res.Line = line + + funcNameElems := strings.Split(funcName, "/") + res.FuncName = funcNameElems[len(funcNameElems)-1] + + return res +} + +// speed-optimized version of formatter, used with individual options only, i.e. without Format call +func (l *Logger) formatWithOptions(elems layout) (res string) { + + orElse := func(flag bool, fnTrue func() string, fnFalse func() string) string { + if flag { + return fnTrue() + } + return fnFalse() + } + nothing := func() string { return "" } + + parts := make([]string, 0, 4) + + parts = append(parts, orElse(l.msec, + func() string { return elems.DT.Format("2006/01/02 15:04:05.000") }, + func() string { return elems.DT.Format("2006/01/02 15:04:05") }, + )) + + parts = append(parts, orElse(l.levelBraces, + func() string { return `[` + elems.Level + `]` }, + func() string { return elems.Level }, + )) + + if l.callerFile || l.callerFunc || l.callerPkg { + var callerParts []string + v := orElse(l.callerFile, func() string { return elems.CallerFile + ":" + strconv.Itoa(elems.CallerLine) }, nothing) + if v != "" { + callerParts = append(callerParts, v) + } + if v := orElse(l.callerFunc, func() string { return elems.CallerFunc }, nothing); v != "" { + callerParts = append(callerParts, v) + } + if v := orElse(l.callerPkg, func() string { return elems.CallerPkg }, nothing); v != "" { + callerParts = append(callerParts, v) + } + parts = append(parts, "{"+strings.Join(callerParts, " ")+"}") + } + + parts = append(parts, elems.Message) + return strings.Join(parts, " ") +} + +// formatLevel aligns level to 5 chars +func (l *Logger) formatLevel(lv string) string { + spaces := "" if len(lv) == 4 { spaces = " " } - return " " + brace("[") + lv + brace("]") + spaces + return lv + spaces } +// extractLevel parses messages with optional level prefix and returns level and the message with stripped level func (l *Logger) extractLevel(line string) (level, msg string) { for _, lv := range levels { if strings.HasPrefix(line, lv) { @@ -173,65 +313,3 @@ func getDump() []byte { } return stacktrace[:length] } - -// Option func type -type Option func(l *Logger) - -// Out sets out writer -func Out(w io.Writer) Option { - return func(l *Logger) { - l.stdout = w - } -} - -// Err sets error writer -func Err(w io.Writer) Option { - return func(l *Logger) { - l.stderr = w - } -} - -// Debug turn on dbg mode -func Debug(l *Logger) { - l.dbg = true -} - -// CallerFile adds caller info with file, and line number -func CallerFile(l *Logger) { - l.callerFile = true -} - -// CallerFunc adds caller info with function name -func CallerFunc(l *Logger) { - l.callerFunc = true -} - -// CallerPkg adds caller's package name -func CallerPkg(l *Logger) { - l.callerPkg = true -} - -// CallerIgnore sets packages skipped from logging caller -func CallerIgnore(ignores ...string) Option { - return func(l *Logger) { - l.ignoredPkgCallers = ignores - } -} - -// CallerSkip sets how many trace levels to skip. -// by default this value is 1 , i.e. skip logger level only -func CallerSkip(n int) Option { - return func(l *Logger) { - l.callerSkip = n - } -} - -// LevelBraces adds [] to level -func LevelBraces(l *Logger) { - l.levelBraces = true -} - -// Msec adds .msec to timestamp -func Msec(l *Logger) { - l.msec = true -} diff --git a/backend/vendor/github.com/go-pkgz/lgr/options.go b/backend/vendor/github.com/go-pkgz/lgr/options.go new file mode 100644 index 00000000..3d790395 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/lgr/options.go @@ -0,0 +1,70 @@ +package lgr + +import "io" + +// Option func type +type Option func(l *Logger) + +// Out sets out writer, stdout by default +func Out(w io.Writer) Option { + return func(l *Logger) { + l.stdout = w + } +} + +// Err sets error writer, stderr by default +func Err(w io.Writer) Option { + return func(l *Logger) { + l.stderr = w + } +} + +// Debug turn on dbg mode +func Debug(l *Logger) { + l.dbg = true +} + +// Trace turn on trace + dbg mode +func Trace(l *Logger) { + l.dbg = true + l.trace = true +} + +// CallerDepth sets number of stack frame skipped for caller reporting, 0 by default +func CallerDepth(n int) Option { + return func(l *Logger) { + l.callerDepth = n + } +} + +// Format sets output layout, overwrites all options for individual parts, i.e. Caller*, Msec and LevelBraces +func Format(f string) Option { + return func(l *Logger) { + l.format = f + } +} + +// CallerFunc adds caller info with function name. Ignored if Format option used. +func CallerFunc(l *Logger) { + l.callerFunc = true +} + +// CallerPkg adds caller's package name. Ignored if Format option used. +func CallerPkg(l *Logger) { + l.callerPkg = true +} + +// LevelBraces surrounds level with [], i.e. [INFO]. Ignored if Format option used. +func LevelBraces(l *Logger) { + l.levelBraces = true +} + +// CallerFile adds caller info with file, and line number. Ignored if Format option used. +func CallerFile(l *Logger) { + l.callerFile = true +} + +// Msec adds .msec to timestamp. Ignored if Format option used. +func Msec(l *Logger) { + l.msec = true +} diff --git a/backend/vendor/github.com/golang/mock/AUTHORS b/backend/vendor/github.com/golang/mock/AUTHORS new file mode 100644 index 00000000..660b8ccc --- /dev/null +++ b/backend/vendor/github.com/golang/mock/AUTHORS @@ -0,0 +1,12 @@ +# This is the official list of GoMock authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS files. +# See the latter for an explanation. + +# Names should be added to this file as +# Name or Organization +# The email address is not required for organizations. + +# Please keep the list sorted. + +Alex Reece +Google Inc. diff --git a/backend/vendor/github.com/golang/mock/CONTRIBUTORS b/backend/vendor/github.com/golang/mock/CONTRIBUTORS new file mode 100644 index 00000000..def849ca --- /dev/null +++ b/backend/vendor/github.com/golang/mock/CONTRIBUTORS @@ -0,0 +1,37 @@ +# This is the official list of people who can contribute (and typically +# have contributed) code to the gomock repository. +# The AUTHORS file lists the copyright holders; this file +# lists people. For example, Google employees are listed here +# but not in AUTHORS, because Google holds the copyright. +# +# The submission process automatically checks to make sure +# that people submitting code are listed in this file (by email address). +# +# Names should be added to this file only after verifying that +# the individual or the individual's organization has agreed to +# the appropriate Contributor License Agreement, found here: +# +# http://code.google.com/legal/individual-cla-v1.0.html +# http://code.google.com/legal/corporate-cla-v1.0.html +# +# The agreement for individuals can be filled out on the web. +# +# When adding J Random Contributor's name to this file, +# either J's name or J's organization's name should be +# added to the AUTHORS file, depending on whether the +# individual or corporate CLA was used. + +# Names should be added to this file like so: +# Name +# +# An entry with two email addresses specifies that the +# first address should be used in the submit logs and +# that the second address should be recognized as the +# same person when interacting with Rietveld. + +# Please keep the list sorted. + +Aaron Jacobs +Alex Reece +David Symonds +Ryan Barrett diff --git a/backend/vendor/github.com/golang/mock/LICENSE b/backend/vendor/github.com/golang/mock/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/backend/vendor/github.com/golang/mock/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/backend/vendor/github.com/golang/mock/gomock/call.go b/backend/vendor/github.com/golang/mock/gomock/call.go new file mode 100644 index 00000000..3d54d9f5 --- /dev/null +++ b/backend/vendor/github.com/golang/mock/gomock/call.go @@ -0,0 +1,420 @@ +// Copyright 2010 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gomock + +import ( + "fmt" + "reflect" + "strconv" + "strings" +) + +// Call represents an expected call to a mock. +type Call struct { + t TestHelper // for triggering test failures on invalid call setup + + receiver interface{} // the receiver of the method call + method string // the name of the method + methodType reflect.Type // the type of the method + args []Matcher // the args + origin string // file and line number of call setup + + preReqs []*Call // prerequisite calls + + // Expectations + minCalls, maxCalls int + + numCalls int // actual number made + + // actions are called when this Call is called. Each action gets the args and + // can set the return values by returning a non-nil slice. Actions run in the + // order they are created. + actions []func([]interface{}) []interface{} +} + +// newCall creates a *Call. It requires the method type in order to support +// unexported methods. +func newCall(t TestHelper, receiver interface{}, method string, methodType reflect.Type, args ...interface{}) *Call { + t.Helper() + + // TODO: check arity, types. + margs := make([]Matcher, len(args)) + for i, arg := range args { + if m, ok := arg.(Matcher); ok { + margs[i] = m + } else if arg == nil { + // Handle nil specially so that passing a nil interface value + // will match the typed nils of concrete args. + margs[i] = Nil() + } else { + margs[i] = Eq(arg) + } + } + + origin := callerInfo(3) + actions := []func([]interface{}) []interface{}{func([]interface{}) []interface{} { + // Synthesize the zero value for each of the return args' types. + rets := make([]interface{}, methodType.NumOut()) + for i := 0; i < methodType.NumOut(); i++ { + rets[i] = reflect.Zero(methodType.Out(i)).Interface() + } + return rets + }} + return &Call{t: t, receiver: receiver, method: method, methodType: methodType, + args: margs, origin: origin, minCalls: 1, maxCalls: 1, actions: actions} +} + +// AnyTimes allows the expectation to be called 0 or more times +func (c *Call) AnyTimes() *Call { + c.minCalls, c.maxCalls = 0, 1e8 // close enough to infinity + return c +} + +// MinTimes requires the call to occur at least n times. If AnyTimes or MaxTimes have not been called, MinTimes also +// sets the maximum number of calls to infinity. +func (c *Call) MinTimes(n int) *Call { + c.minCalls = n + if c.maxCalls == 1 { + c.maxCalls = 1e8 + } + return c +} + +// MaxTimes limits the number of calls to n times. If AnyTimes or MinTimes have not been called, MaxTimes also +// sets the minimum number of calls to 0. +func (c *Call) MaxTimes(n int) *Call { + c.maxCalls = n + if c.minCalls == 1 { + c.minCalls = 0 + } + return c +} + +// DoAndReturn declares the action to run when the call is matched. +// The return values from this function are returned by the mocked function. +// It takes an interface{} argument to support n-arity functions. +func (c *Call) DoAndReturn(f interface{}) *Call { + // TODO: Check arity and types here, rather than dying badly elsewhere. + v := reflect.ValueOf(f) + + c.addAction(func(args []interface{}) []interface{} { + vargs := make([]reflect.Value, len(args)) + ft := v.Type() + for i := 0; i < len(args); i++ { + if args[i] != nil { + vargs[i] = reflect.ValueOf(args[i]) + } else { + // Use the zero value for the arg. + vargs[i] = reflect.Zero(ft.In(i)) + } + } + vrets := v.Call(vargs) + rets := make([]interface{}, len(vrets)) + for i, ret := range vrets { + rets[i] = ret.Interface() + } + return rets + }) + return c +} + +// Do declares the action to run when the call is matched. The function's +// return values are ignored to retain backward compatibility. To use the +// return values call DoAndReturn. +// It takes an interface{} argument to support n-arity functions. +func (c *Call) Do(f interface{}) *Call { + // TODO: Check arity and types here, rather than dying badly elsewhere. + v := reflect.ValueOf(f) + + c.addAction(func(args []interface{}) []interface{} { + vargs := make([]reflect.Value, len(args)) + ft := v.Type() + for i := 0; i < len(args); i++ { + if args[i] != nil { + vargs[i] = reflect.ValueOf(args[i]) + } else { + // Use the zero value for the arg. + vargs[i] = reflect.Zero(ft.In(i)) + } + } + v.Call(vargs) + return nil + }) + return c +} + +// Return declares the values to be returned by the mocked function call. +func (c *Call) Return(rets ...interface{}) *Call { + c.t.Helper() + + mt := c.methodType + if len(rets) != mt.NumOut() { + c.t.Fatalf("wrong number of arguments to Return for %T.%v: got %d, want %d [%s]", + c.receiver, c.method, len(rets), mt.NumOut(), c.origin) + } + for i, ret := range rets { + if got, want := reflect.TypeOf(ret), mt.Out(i); got == want { + // Identical types; nothing to do. + } else if got == nil { + // Nil needs special handling. + switch want.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + // ok + default: + c.t.Fatalf("argument %d to Return for %T.%v is nil, but %v is not nillable [%s]", + i, c.receiver, c.method, want, c.origin) + } + } else if got.AssignableTo(want) { + // Assignable type relation. Make the assignment now so that the generated code + // can return the values with a type assertion. + v := reflect.New(want).Elem() + v.Set(reflect.ValueOf(ret)) + rets[i] = v.Interface() + } else { + c.t.Fatalf("wrong type of argument %d to Return for %T.%v: %v is not assignable to %v [%s]", + i, c.receiver, c.method, got, want, c.origin) + } + } + + c.addAction(func([]interface{}) []interface{} { + return rets + }) + + return c +} + +// Times declares the exact number of times a function call is expected to be executed. +func (c *Call) Times(n int) *Call { + c.minCalls, c.maxCalls = n, n + return c +} + +// SetArg declares an action that will set the nth argument's value, +// indirected through a pointer. Or, in the case of a slice, SetArg +// will copy value's elements into the nth argument. +func (c *Call) SetArg(n int, value interface{}) *Call { + c.t.Helper() + + mt := c.methodType + // TODO: This will break on variadic methods. + // We will need to check those at invocation time. + if n < 0 || n >= mt.NumIn() { + c.t.Fatalf("SetArg(%d, ...) called for a method with %d args [%s]", + n, mt.NumIn(), c.origin) + } + // Permit setting argument through an interface. + // In the interface case, we don't (nay, can't) check the type here. + at := mt.In(n) + switch at.Kind() { + case reflect.Ptr: + dt := at.Elem() + if vt := reflect.TypeOf(value); !vt.AssignableTo(dt) { + c.t.Fatalf("SetArg(%d, ...) argument is a %v, not assignable to %v [%s]", + n, vt, dt, c.origin) + } + case reflect.Interface: + // nothing to do + case reflect.Slice: + // nothing to do + default: + c.t.Fatalf("SetArg(%d, ...) referring to argument of non-pointer non-interface non-slice type %v [%s]", + n, at, c.origin) + } + + c.addAction(func(args []interface{}) []interface{} { + v := reflect.ValueOf(value) + switch reflect.TypeOf(args[n]).Kind() { + case reflect.Slice: + setSlice(args[n], v) + default: + reflect.ValueOf(args[n]).Elem().Set(v) + } + return nil + }) + return c +} + +// isPreReq returns true if other is a direct or indirect prerequisite to c. +func (c *Call) isPreReq(other *Call) bool { + for _, preReq := range c.preReqs { + if other == preReq || preReq.isPreReq(other) { + return true + } + } + return false +} + +// After declares that the call may only match after preReq has been exhausted. +func (c *Call) After(preReq *Call) *Call { + c.t.Helper() + + if c == preReq { + c.t.Fatalf("A call isn't allowed to be its own prerequisite") + } + if preReq.isPreReq(c) { + c.t.Fatalf("Loop in call order: %v is a prerequisite to %v (possibly indirectly).", c, preReq) + } + + c.preReqs = append(c.preReqs, preReq) + return c +} + +// Returns true if the minimum number of calls have been made. +func (c *Call) satisfied() bool { + return c.numCalls >= c.minCalls +} + +// Returns true iff the maximum number of calls have been made. +func (c *Call) exhausted() bool { + return c.numCalls >= c.maxCalls +} + +func (c *Call) String() string { + args := make([]string, len(c.args)) + for i, arg := range c.args { + args[i] = arg.String() + } + arguments := strings.Join(args, ", ") + return fmt.Sprintf("%T.%v(%s) %s", c.receiver, c.method, arguments, c.origin) +} + +// Tests if the given call matches the expected call. +// If yes, returns nil. If no, returns error with message explaining why it does not match. +func (c *Call) matches(args []interface{}) error { + if !c.methodType.IsVariadic() { + if len(args) != len(c.args) { + return fmt.Errorf("Expected call at %s has the wrong number of arguments. Got: %d, want: %d", + c.origin, len(args), len(c.args)) + } + + for i, m := range c.args { + if !m.Matches(args[i]) { + return fmt.Errorf("Expected call at %s doesn't match the argument at index %s.\nGot: %v\nWant: %v", + c.origin, strconv.Itoa(i), args[i], m) + } + } + } else { + if len(c.args) < c.methodType.NumIn()-1 { + return fmt.Errorf("Expected call at %s has the wrong number of matchers. Got: %d, want: %d", + c.origin, len(c.args), c.methodType.NumIn()-1) + } + if len(c.args) != c.methodType.NumIn() && len(args) != len(c.args) { + return fmt.Errorf("Expected call at %s has the wrong number of arguments. Got: %d, want: %d", + c.origin, len(args), len(c.args)) + } + if len(args) < len(c.args)-1 { + return fmt.Errorf("Expected call at %s has the wrong number of arguments. Got: %d, want: greater than or equal to %d", + c.origin, len(args), len(c.args)-1) + } + + for i, m := range c.args { + if i < c.methodType.NumIn()-1 { + // Non-variadic args + if !m.Matches(args[i]) { + return fmt.Errorf("Expected call at %s doesn't match the argument at index %s.\nGot: %v\nWant: %v", + c.origin, strconv.Itoa(i), args[i], m) + } + continue + } + // The last arg has a possibility of a variadic argument, so let it branch + + // sample: Foo(a int, b int, c ...int) + if i < len(c.args) && i < len(args) { + if m.Matches(args[i]) { + // Got Foo(a, b, c) want Foo(matcherA, matcherB, gomock.Any()) + // Got Foo(a, b, c) want Foo(matcherA, matcherB, someSliceMatcher) + // Got Foo(a, b, c) want Foo(matcherA, matcherB, matcherC) + // Got Foo(a, b) want Foo(matcherA, matcherB) + // Got Foo(a, b, c, d) want Foo(matcherA, matcherB, matcherC, matcherD) + continue + } + } + + // The number of actual args don't match the number of matchers, + // or the last matcher is a slice and the last arg is not. + // If this function still matches it is because the last matcher + // matches all the remaining arguments or the lack of any. + // Convert the remaining arguments, if any, into a slice of the + // expected type. + vargsType := c.methodType.In(c.methodType.NumIn() - 1) + vargs := reflect.MakeSlice(vargsType, 0, len(args)-i) + for _, arg := range args[i:] { + vargs = reflect.Append(vargs, reflect.ValueOf(arg)) + } + if m.Matches(vargs.Interface()) { + // Got Foo(a, b, c, d, e) want Foo(matcherA, matcherB, gomock.Any()) + // Got Foo(a, b, c, d, e) want Foo(matcherA, matcherB, someSliceMatcher) + // Got Foo(a, b) want Foo(matcherA, matcherB, gomock.Any()) + // Got Foo(a, b) want Foo(matcherA, matcherB, someEmptySliceMatcher) + break + } + // Wrong number of matchers or not match. Fail. + // Got Foo(a, b) want Foo(matcherA, matcherB, matcherC, matcherD) + // Got Foo(a, b, c) want Foo(matcherA, matcherB, matcherC, matcherD) + // Got Foo(a, b, c, d) want Foo(matcherA, matcherB, matcherC, matcherD, matcherE) + // Got Foo(a, b, c, d, e) want Foo(matcherA, matcherB, matcherC, matcherD) + // Got Foo(a, b, c) want Foo(matcherA, matcherB) + return fmt.Errorf("Expected call at %s doesn't match the argument at index %s.\nGot: %v\nWant: %v", + c.origin, strconv.Itoa(i), args[i:], c.args[i]) + + } + } + + // Check that all prerequisite calls have been satisfied. + for _, preReqCall := range c.preReqs { + if !preReqCall.satisfied() { + return fmt.Errorf("Expected call at %s doesn't have a prerequisite call satisfied:\n%v\nshould be called before:\n%v", + c.origin, preReqCall, c) + } + } + + // Check that the call is not exhausted. + if c.exhausted() { + return fmt.Errorf("Expected call at %s has already been called the max number of times.", c.origin) + } + + return nil +} + +// dropPrereqs tells the expected Call to not re-check prerequisite calls any +// longer, and to return its current set. +func (c *Call) dropPrereqs() (preReqs []*Call) { + preReqs = c.preReqs + c.preReqs = nil + return +} + +func (c *Call) call(args []interface{}) []func([]interface{}) []interface{} { + c.numCalls++ + return c.actions +} + +// InOrder declares that the given calls should occur in order. +func InOrder(calls ...*Call) { + for i := 1; i < len(calls); i++ { + calls[i].After(calls[i-1]) + } +} + +func setSlice(arg interface{}, v reflect.Value) { + va := reflect.ValueOf(arg) + for i := 0; i < v.Len(); i++ { + va.Index(i).Set(v.Index(i)) + } +} + +func (c *Call) addAction(action func([]interface{}) []interface{}) { + c.actions = append(c.actions, action) +} diff --git a/backend/vendor/github.com/golang/mock/gomock/callset.go b/backend/vendor/github.com/golang/mock/gomock/callset.go new file mode 100644 index 00000000..c44a8a58 --- /dev/null +++ b/backend/vendor/github.com/golang/mock/gomock/callset.go @@ -0,0 +1,108 @@ +// Copyright 2011 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gomock + +import ( + "bytes" + "fmt" +) + +// callSet represents a set of expected calls, indexed by receiver and method +// name. +type callSet struct { + // Calls that are still expected. + expected map[callSetKey][]*Call + // Calls that have been exhausted. + exhausted map[callSetKey][]*Call +} + +// callSetKey is the key in the maps in callSet +type callSetKey struct { + receiver interface{} + fname string +} + +func newCallSet() *callSet { + return &callSet{make(map[callSetKey][]*Call), make(map[callSetKey][]*Call)} +} + +// Add adds a new expected call. +func (cs callSet) Add(call *Call) { + key := callSetKey{call.receiver, call.method} + m := cs.expected + if call.exhausted() { + m = cs.exhausted + } + m[key] = append(m[key], call) +} + +// Remove removes an expected call. +func (cs callSet) Remove(call *Call) { + key := callSetKey{call.receiver, call.method} + calls := cs.expected[key] + for i, c := range calls { + if c == call { + // maintain order for remaining calls + cs.expected[key] = append(calls[:i], calls[i+1:]...) + cs.exhausted[key] = append(cs.exhausted[key], call) + break + } + } +} + +// FindMatch searches for a matching call. Returns error with explanation message if no call matched. +func (cs callSet) FindMatch(receiver interface{}, method string, args []interface{}) (*Call, error) { + key := callSetKey{receiver, method} + + // Search through the expected calls. + expected := cs.expected[key] + var callsErrors bytes.Buffer + for _, call := range expected { + err := call.matches(args) + if err != nil { + fmt.Fprintf(&callsErrors, "\n%v", err) + } else { + return call, nil + } + } + + // If we haven't found a match then search through the exhausted calls so we + // get useful error messages. + exhausted := cs.exhausted[key] + for _, call := range exhausted { + if err := call.matches(args); err != nil { + fmt.Fprintf(&callsErrors, "\n%v", err) + } + } + + if len(expected)+len(exhausted) == 0 { + fmt.Fprintf(&callsErrors, "there are no expected calls of the method %q for that receiver", method) + } + + return nil, fmt.Errorf(callsErrors.String()) +} + +// Failures returns the calls that are not satisfied. +func (cs callSet) Failures() []*Call { + failures := make([]*Call, 0, len(cs.expected)) + for _, calls := range cs.expected { + for _, call := range calls { + if !call.satisfied() { + failures = append(failures, call) + } + } + } + return failures +} diff --git a/backend/vendor/github.com/golang/mock/gomock/controller.go b/backend/vendor/github.com/golang/mock/gomock/controller.go new file mode 100644 index 00000000..6fde25f5 --- /dev/null +++ b/backend/vendor/github.com/golang/mock/gomock/controller.go @@ -0,0 +1,235 @@ +// Copyright 2010 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// GoMock - a mock framework for Go. +// +// Standard usage: +// (1) Define an interface that you wish to mock. +// type MyInterface interface { +// SomeMethod(x int64, y string) +// } +// (2) Use mockgen to generate a mock from the interface. +// (3) Use the mock in a test: +// func TestMyThing(t *testing.T) { +// mockCtrl := gomock.NewController(t) +// defer mockCtrl.Finish() +// +// mockObj := something.NewMockMyInterface(mockCtrl) +// mockObj.EXPECT().SomeMethod(4, "blah") +// // pass mockObj to a real object and play with it. +// } +// +// By default, expected calls are not enforced to run in any particular order. +// Call order dependency can be enforced by use of InOrder and/or Call.After. +// Call.After can create more varied call order dependencies, but InOrder is +// often more convenient. +// +// The following examples create equivalent call order dependencies. +// +// Example of using Call.After to chain expected call order: +// +// firstCall := mockObj.EXPECT().SomeMethod(1, "first") +// secondCall := mockObj.EXPECT().SomeMethod(2, "second").After(firstCall) +// mockObj.EXPECT().SomeMethod(3, "third").After(secondCall) +// +// Example of using InOrder to declare expected call order: +// +// gomock.InOrder( +// mockObj.EXPECT().SomeMethod(1, "first"), +// mockObj.EXPECT().SomeMethod(2, "second"), +// mockObj.EXPECT().SomeMethod(3, "third"), +// ) +// +// TODO: +// - Handle different argument/return types (e.g. ..., chan, map, interface). +package gomock + +import ( + "context" + "fmt" + "reflect" + "runtime" + "sync" +) + +// A TestReporter is something that can be used to report test failures. +// It is satisfied by the standard library's *testing.T. +type TestReporter interface { + Errorf(format string, args ...interface{}) + Fatalf(format string, args ...interface{}) +} + +// TestHelper is a TestReporter that has the Helper method. It is satisfied +// by the standard library's *testing.T. +type TestHelper interface { + TestReporter + Helper() +} + +// A Controller represents the top-level control of a mock ecosystem. +// It defines the scope and lifetime of mock objects, as well as their expectations. +// It is safe to call Controller's methods from multiple goroutines. +type Controller struct { + // T should only be called within a generated mock. It is not intended to + // be used in user code and may be changed in future versions. T is the + // TestReporter passed in when creating the Controller via NewController. + // If the TestReporter does not implment a TestHelper it will be wrapped + // with a nopTestHelper. + T TestHelper + mu sync.Mutex + expectedCalls *callSet + finished bool +} + +func NewController(t TestReporter) *Controller { + h, ok := t.(TestHelper) + if !ok { + h = nopTestHelper{t} + } + + return &Controller{ + T: h, + expectedCalls: newCallSet(), + } +} + +type cancelReporter struct { + TestHelper + cancel func() +} + +func (r *cancelReporter) Errorf(format string, args ...interface{}) { + r.TestHelper.Errorf(format, args...) +} +func (r *cancelReporter) Fatalf(format string, args ...interface{}) { + defer r.cancel() + r.TestHelper.Fatalf(format, args...) +} + +// WithContext returns a new Controller and a Context, which is cancelled on any +// fatal failure. +func WithContext(ctx context.Context, t TestReporter) (*Controller, context.Context) { + h, ok := t.(TestHelper) + if !ok { + h = nopTestHelper{t} + } + + ctx, cancel := context.WithCancel(ctx) + return NewController(&cancelReporter{h, cancel}), ctx +} + +type nopTestHelper struct { + TestReporter +} + +func (h nopTestHelper) Helper() {} + +func (ctrl *Controller) RecordCall(receiver interface{}, method string, args ...interface{}) *Call { + ctrl.T.Helper() + + recv := reflect.ValueOf(receiver) + for i := 0; i < recv.Type().NumMethod(); i++ { + if recv.Type().Method(i).Name == method { + return ctrl.RecordCallWithMethodType(receiver, method, recv.Method(i).Type(), args...) + } + } + ctrl.T.Fatalf("gomock: failed finding method %s on %T", method, receiver) + panic("unreachable") +} + +func (ctrl *Controller) RecordCallWithMethodType(receiver interface{}, method string, methodType reflect.Type, args ...interface{}) *Call { + ctrl.T.Helper() + + call := newCall(ctrl.T, receiver, method, methodType, args...) + + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + ctrl.expectedCalls.Add(call) + + return call +} + +func (ctrl *Controller) Call(receiver interface{}, method string, args ...interface{}) []interface{} { + ctrl.T.Helper() + + // Nest this code so we can use defer to make sure the lock is released. + actions := func() []func([]interface{}) []interface{} { + ctrl.T.Helper() + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + + expected, err := ctrl.expectedCalls.FindMatch(receiver, method, args) + if err != nil { + origin := callerInfo(2) + ctrl.T.Fatalf("Unexpected call to %T.%v(%v) at %s because: %s", receiver, method, args, origin, err) + } + + // Two things happen here: + // * the matching call no longer needs to check prerequite calls, + // * and the prerequite calls are no longer expected, so remove them. + preReqCalls := expected.dropPrereqs() + for _, preReqCall := range preReqCalls { + ctrl.expectedCalls.Remove(preReqCall) + } + + actions := expected.call(args) + if expected.exhausted() { + ctrl.expectedCalls.Remove(expected) + } + return actions + }() + + var rets []interface{} + for _, action := range actions { + if r := action(args); r != nil { + rets = r + } + } + + return rets +} + +func (ctrl *Controller) Finish() { + ctrl.T.Helper() + + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + + if ctrl.finished { + ctrl.T.Fatalf("Controller.Finish was called more than once. It has to be called exactly once.") + } + ctrl.finished = true + + // If we're currently panicking, probably because this is a deferred call, + // pass through the panic. + if err := recover(); err != nil { + panic(err) + } + + // Check that all remaining expected calls are satisfied. + failures := ctrl.expectedCalls.Failures() + for _, call := range failures { + ctrl.T.Errorf("missing call(s) to %v", call) + } + if len(failures) != 0 { + ctrl.T.Fatalf("aborting test due to missing call(s)") + } +} + +func callerInfo(skip int) string { + if _, file, line, ok := runtime.Caller(skip + 1); ok { + return fmt.Sprintf("%s:%d", file, line) + } + return "unknown file" +} diff --git a/backend/vendor/github.com/golang/mock/gomock/matchers.go b/backend/vendor/github.com/golang/mock/gomock/matchers.go new file mode 100644 index 00000000..189796f8 --- /dev/null +++ b/backend/vendor/github.com/golang/mock/gomock/matchers.go @@ -0,0 +1,122 @@ +// Copyright 2010 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gomock + +import ( + "fmt" + "reflect" +) + +// A Matcher is a representation of a class of values. +// It is used to represent the valid or expected arguments to a mocked method. +type Matcher interface { + // Matches returns whether x is a match. + Matches(x interface{}) bool + + // String describes what the matcher matches. + String() string +} + +type anyMatcher struct{} + +func (anyMatcher) Matches(x interface{}) bool { + return true +} + +func (anyMatcher) String() string { + return "is anything" +} + +type eqMatcher struct { + x interface{} +} + +func (e eqMatcher) Matches(x interface{}) bool { + return reflect.DeepEqual(e.x, x) +} + +func (e eqMatcher) String() string { + return fmt.Sprintf("is equal to %v", e.x) +} + +type nilMatcher struct{} + +func (nilMatcher) Matches(x interface{}) bool { + if x == nil { + return true + } + + v := reflect.ValueOf(x) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, + reflect.Ptr, reflect.Slice: + return v.IsNil() + } + + return false +} + +func (nilMatcher) String() string { + return "is nil" +} + +type notMatcher struct { + m Matcher +} + +func (n notMatcher) Matches(x interface{}) bool { + return !n.m.Matches(x) +} + +func (n notMatcher) String() string { + // TODO: Improve this if we add a NotString method to the Matcher interface. + return "not(" + n.m.String() + ")" +} + +type assignableToTypeOfMatcher struct { + targetType reflect.Type +} + +func (m assignableToTypeOfMatcher) Matches(x interface{}) bool { + return reflect.TypeOf(x).AssignableTo(m.targetType) +} + +func (m assignableToTypeOfMatcher) String() string { + return "is assignable to " + m.targetType.Name() +} + +// Constructors +func Any() Matcher { return anyMatcher{} } +func Eq(x interface{}) Matcher { return eqMatcher{x} } +func Nil() Matcher { return nilMatcher{} } +func Not(x interface{}) Matcher { + if m, ok := x.(Matcher); ok { + return notMatcher{m} + } + return notMatcher{Eq(x)} +} + +// AssignableToTypeOf is a Matcher that matches if the parameter to the mock +// function is assignable to the type of the parameter to this function. +// +// Example usage: +// +// dbMock.EXPECT(). +// Insert(gomock.AssignableToTypeOf(&EmployeeRecord{})). +// Return(errors.New("DB error")) +// +func AssignableToTypeOf(x interface{}) Matcher { + return assignableToTypeOfMatcher{reflect.TypeOf(x)} +} diff --git a/backend/vendor/github.com/hashicorp/errwrap/README.md b/backend/vendor/github.com/hashicorp/errwrap/README.md index 1c95f597..444df08f 100644 --- a/backend/vendor/github.com/hashicorp/errwrap/README.md +++ b/backend/vendor/github.com/hashicorp/errwrap/README.md @@ -48,7 +48,7 @@ func main() { // We can use the Contains helpers to check if an error contains // another error. It is safe to do this with a nil error, or with // an error that doesn't even use the errwrap package. - if errwrap.Contains(err, ErrNotExist) { + if errwrap.Contains(err, "does not exist") { // Do something } if errwrap.ContainsType(err, new(os.PathError)) { diff --git a/backend/vendor/github.com/hashicorp/errwrap/go.mod b/backend/vendor/github.com/hashicorp/errwrap/go.mod new file mode 100644 index 00000000..c9b84022 --- /dev/null +++ b/backend/vendor/github.com/hashicorp/errwrap/go.mod @@ -0,0 +1 @@ +module github.com/hashicorp/errwrap diff --git a/backend/vendor/github.com/hashicorp/golang-lru/lru.go b/backend/vendor/github.com/hashicorp/golang-lru/lru.go index 1cbe04b7..c8d9b0a2 100644 --- a/backend/vendor/github.com/hashicorp/golang-lru/lru.go +++ b/backend/vendor/github.com/hashicorp/golang-lru/lru.go @@ -40,35 +40,31 @@ func (c *Cache) Purge() { // Add adds a value to the cache. Returns true if an eviction occurred. func (c *Cache) Add(key, value interface{}) (evicted bool) { c.lock.Lock() - evicted = c.lru.Add(key, value) - c.lock.Unlock() - return evicted + defer c.lock.Unlock() + return c.lru.Add(key, value) } // Get looks up a key's value from the cache. func (c *Cache) Get(key interface{}) (value interface{}, ok bool) { c.lock.Lock() - value, ok = c.lru.Get(key) - c.lock.Unlock() - return value, ok + defer c.lock.Unlock() + return c.lru.Get(key) } // Contains checks if a key is in the cache, without updating the // recent-ness or deleting it for being stale. func (c *Cache) Contains(key interface{}) bool { c.lock.RLock() - containKey := c.lru.Contains(key) - c.lock.RUnlock() - return containKey + defer c.lock.RUnlock() + return c.lru.Contains(key) } // Peek returns the key value (or undefined if not found) without updating // the "recently used"-ness of the key. func (c *Cache) Peek(key interface{}) (value interface{}, ok bool) { c.lock.RLock() - value, ok = c.lru.Peek(key) - c.lock.RUnlock() - return value, ok + defer c.lock.RUnlock() + return c.lru.Peek(key) } // ContainsOrAdd checks if a key is in the cache without updating the @@ -102,15 +98,13 @@ func (c *Cache) RemoveOldest() { // Keys returns a slice of the keys in the cache, from oldest to newest. func (c *Cache) Keys() []interface{} { c.lock.RLock() - keys := c.lru.Keys() - c.lock.RUnlock() - return keys + defer c.lock.RUnlock() + return c.lru.Keys() } // Len returns the number of items in the cache. func (c *Cache) Len() int { c.lock.RLock() - length := c.lru.Len() - c.lock.RUnlock() - return length + defer c.lock.RUnlock() + return c.lru.Len() } diff --git a/backend/vendor/github.com/shurcooL/sanitized_anchor_name/go.mod b/backend/vendor/github.com/shurcooL/sanitized_anchor_name/go.mod new file mode 100644 index 00000000..1e255347 --- /dev/null +++ b/backend/vendor/github.com/shurcooL/sanitized_anchor_name/go.mod @@ -0,0 +1 @@ +module github.com/shurcooL/sanitized_anchor_name diff --git a/backend/vendor/golang.org/x/time/rate/rate.go b/backend/vendor/golang.org/x/time/rate/rate.go index eabcd114..ae93e247 100644 --- a/backend/vendor/golang.org/x/time/rate/rate.go +++ b/backend/vendor/golang.org/x/time/rate/rate.go @@ -6,6 +6,7 @@ package rate import ( + "context" "fmt" "math" "sync" @@ -212,19 +213,8 @@ func (lim *Limiter) ReserveN(now time.Time, n int) *Reservation { return &r } -// contextContext is a temporary(?) copy of the context.Context type -// to support both Go 1.6 using golang.org/x/net/context and Go 1.7+ -// with the built-in context package. If people ever stop using Go 1.6 -// we can remove this. -type contextContext interface { - Deadline() (deadline time.Time, ok bool) - Done() <-chan struct{} - Err() error - Value(key interface{}) interface{} -} - // Wait is shorthand for WaitN(ctx, 1). -func (lim *Limiter) wait(ctx contextContext) (err error) { +func (lim *Limiter) Wait(ctx context.Context) (err error) { return lim.WaitN(ctx, 1) } @@ -232,7 +222,7 @@ func (lim *Limiter) wait(ctx contextContext) (err error) { // It returns an error if n exceeds the Limiter's burst size, the Context is // canceled, or the expected wait time exceeds the Context's Deadline. // The burst limit is ignored if the rate limit is Inf. -func (lim *Limiter) waitN(ctx contextContext, n int) (err error) { +func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) { if n > lim.burst && lim.limit != Inf { return fmt.Errorf("rate: Wait(n=%d) exceeds limiter's burst %d", n, lim.burst) } @@ -253,8 +243,12 @@ func (lim *Limiter) waitN(ctx contextContext, n int) (err error) { if !r.ok { return fmt.Errorf("rate: Wait(n=%d) would exceed context deadline", n) } - // Wait - t := time.NewTimer(r.DelayFrom(now)) + // Wait if necessary + delay := r.DelayFrom(now) + if delay == 0 { + return nil + } + t := time.NewTimer(delay) defer t.Stop() select { case <-t.C: diff --git a/backend/vendor/golang.org/x/time/rate/rate_go16.go b/backend/vendor/golang.org/x/time/rate/rate_go16.go deleted file mode 100644 index 6bab1850..00000000 --- a/backend/vendor/golang.org/x/time/rate/rate_go16.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2017 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build !go1.7 - -package rate - -import "golang.org/x/net/context" - -// Wait is shorthand for WaitN(ctx, 1). -func (lim *Limiter) Wait(ctx context.Context) (err error) { - return lim.waitN(ctx, 1) -} - -// WaitN blocks until lim permits n events to happen. -// It returns an error if n exceeds the Limiter's burst size, the Context is -// canceled, or the expected wait time exceeds the Context's Deadline. -func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) { - return lim.waitN(ctx, n) -} diff --git a/backend/vendor/golang.org/x/time/rate/rate_go17.go b/backend/vendor/golang.org/x/time/rate/rate_go17.go deleted file mode 100644 index f90d85f5..00000000 --- a/backend/vendor/golang.org/x/time/rate/rate_go17.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2017 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build go1.7 - -package rate - -import "context" - -// Wait is shorthand for WaitN(ctx, 1). -func (lim *Limiter) Wait(ctx context.Context) (err error) { - return lim.waitN(ctx, 1) -} - -// WaitN blocks until lim permits n events to happen. -// It returns an error if n exceeds the Limiter's burst size, the Context is -// canceled, or the expected wait time exceeds the Context's Deadline. -func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) { - return lim.waitN(ctx, n) -} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index 4863c4d0..55764647 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -23,7 +23,7 @@ github.com/globalsign/mgo/bson github.com/globalsign/mgo/internal/sasl github.com/globalsign/mgo/internal/scram github.com/globalsign/mgo/internal/json -# github.com/go-chi/chi v3.3.2+incompatible +# github.com/go-chi/chi v4.0.2+incompatible github.com/go-chi/chi github.com/go-chi/chi/middleware # github.com/go-chi/cors v1.0.0 @@ -39,7 +39,7 @@ github.com/go-pkgz/auth/logger github.com/go-pkgz/auth/middleware # github.com/go-pkgz/lcw v0.2.0 github.com/go-pkgz/lcw -# github.com/go-pkgz/lgr v0.4.0 +# github.com/go-pkgz/lgr v0.6.2 github.com/go-pkgz/lgr # github.com/go-pkgz/mongo v1.1.2 github.com/go-pkgz/mongo @@ -52,17 +52,19 @@ github.com/go-pkgz/rest github.com/go-pkgz/rest/logger # github.com/go-pkgz/syncs v1.1.0 github.com/go-pkgz/syncs +# github.com/golang/mock v1.2.0 +github.com/golang/mock/gomock # github.com/golang/protobuf v1.2.0 github.com/golang/protobuf/proto # github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c github.com/google/uuid # github.com/gorilla/feeds v1.1.0 github.com/gorilla/feeds -# github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce +# github.com/hashicorp/errwrap v1.0.0 github.com/hashicorp/errwrap # github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874 github.com/hashicorp/go-multierror -# github.com/hashicorp/golang-lru v0.5.1 +# github.com/hashicorp/golang-lru v0.5.0 github.com/hashicorp/golang-lru github.com/hashicorp/golang-lru/simplelru # github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc @@ -79,7 +81,7 @@ github.com/pkg/errors github.com/pmezard/go-difflib/difflib # github.com/rakyll/statik v0.1.3 github.com/rakyll/statik/fs -# github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 +# github.com/shurcooL/sanitized_anchor_name v1.0.0 github.com/shurcooL/sanitized_anchor_name # github.com/stretchr/testify v1.3.0 github.com/stretchr/testify/assert @@ -106,7 +108,7 @@ golang.org/x/oauth2/jws golang.org/x/oauth2/jwt # golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb golang.org/x/sys/unix -# golang.org/x/time v0.0.0-20170927054726-6dc17368e09b +# golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 golang.org/x/time/rate # google.golang.org/appengine v1.4.0 google.golang.org/appengine