Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8357846818 | ||
|
|
31ea91afb8 | ||
|
|
6616541f65 | ||
|
|
01695822bb | ||
|
|
f0186d1aab | ||
|
|
41a3359085 | ||
|
|
d6cce8df2c | ||
|
|
596861a594 | ||
|
|
385ea800a4 | ||
|
|
27fc339e36 | ||
|
|
61e2173f25 | ||
|
|
13a3fc3d1b | ||
|
|
6a1b515ea9 | ||
|
|
82f27e6b63 | ||
|
|
6ac75031ad | ||
|
|
8b7f1331ee | ||
|
|
099aad8475 | ||
|
|
c1b3fba344 | ||
|
|
d7e9be99f9 | ||
|
|
067a8bcb21 | ||
|
|
f5569a62f1 | ||
|
|
1ab1ed8a82 | ||
|
|
8d95baa70f | ||
|
|
050bce163a | ||
|
|
68b84683d0 | ||
|
|
6fe373d540 | ||
|
|
ba19bcc729 | ||
|
|
f1b65db2c7 | ||
|
|
f5f287ef06 | ||
|
|
f161e6033c | ||
|
|
c86bff8811 | ||
|
|
596b1045bd | ||
|
|
907ca2b590 | ||
|
|
f1b5469b83 | ||
|
|
984fbde540 | ||
|
|
2bdc05dd47 | ||
|
|
d2ea572abf | ||
|
|
8d5c4cd578 |
@@ -21,8 +21,10 @@ compose-dev-backend.yml
|
||||
compose-dev-frontend.yml
|
||||
compose-private-backend.yml
|
||||
compose-private-frontend.yml
|
||||
compose-e2e-test.yml
|
||||
compose-private.yml
|
||||
rest-client.env.json
|
||||
Makefile
|
||||
|
||||
# generated files
|
||||
*.cov
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
DEBUG: ${{secrets.DEBUG}}
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.17
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ name: build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
paths:
|
||||
- ".github/workflows/ci-build.yml"
|
||||
@@ -33,11 +32,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
@@ -23,11 +23,11 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
+2
-4
@@ -96,20 +96,18 @@ LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
ADD docker-init.sh /entrypoint.sh
|
||||
COPY docker-init.sh /srv/init.sh
|
||||
ADD backend/scripts/backup.sh /usr/local/bin/backup
|
||||
ADD backend/scripts/restore.sh /usr/local/bin/restore
|
||||
ADD backend/scripts/import.sh /usr/local/bin/import
|
||||
RUN chmod +x /entrypoint.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
|
||||
RUN chmod +x /srv/init.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
|
||||
|
||||
COPY --from=build-backend /build/backend/remark42 /srv/remark42
|
||||
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /srv/web/
|
||||
COPY docker-init.sh /srv/init.sh
|
||||
RUN chown -R app:app /srv
|
||||
RUN ln -s /srv/remark42 /usr/bin/remark42
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost:8080/ping || exit 1
|
||||
|
||||
RUN chmod +x /srv/init.sh
|
||||
CMD ["/srv/remark42", "server"]
|
||||
|
||||
@@ -49,4 +49,7 @@ rundev:
|
||||
docker-compose -f compose-private.yml build
|
||||
docker-compose -f compose-private.yml up
|
||||
|
||||
e2e:
|
||||
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
|
||||
.PHONY: bin backend
|
||||
|
||||
@@ -35,7 +35,6 @@ linters:
|
||||
- revive
|
||||
- govet
|
||||
- unconvert
|
||||
- megacheck
|
||||
- gas
|
||||
- gocyclo
|
||||
- dupl
|
||||
|
||||
@@ -35,7 +35,8 @@ func NewMemAdminStore(key string) *MemAdmin {
|
||||
return &MemAdmin{data: map[string]AdminRec{}, key: key}
|
||||
}
|
||||
|
||||
// Key executes find by siteID and returns substructure with secret key
|
||||
// Key supposed to execute find by siteID and returns substructure with secret key,
|
||||
// but in this case the shared secret is used for all sites
|
||||
func (m *MemAdmin) Key(_ string) (key string, err error) {
|
||||
return m.key, nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ package accessor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
)
|
||||
@@ -267,7 +267,7 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
|
||||
return res, nil
|
||||
|
||||
case engine.Blocked:
|
||||
log.Printf("%+v", m.metaUsers)
|
||||
log.Printf("[INFO] metaUsers: %+v", m.metaUsers)
|
||||
for _, u := range m.metaUsers {
|
||||
if u.SiteID == req.Locator.SiteID && u.Blocked && u.BlockedUntil.After(time.Now()) {
|
||||
res = append(res, store.BlockedUser{ID: u.UserID, Until: u.BlockedUntil})
|
||||
|
||||
@@ -624,6 +624,7 @@ func TestMemData_DeleteComment(t *testing.T) {
|
||||
func TestMemData_Close(t *testing.T) {
|
||||
b := prepMem(t)
|
||||
assert.NoError(t, b.Close())
|
||||
assert.NoError(t, b.Close(), "second call should not result in panic or errors")
|
||||
}
|
||||
|
||||
func TestMemData_DeleteHard(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ require (
|
||||
github.com/go-pkgz/jrpc v0.3.0
|
||||
github.com/go-pkgz/lgr v0.10.4
|
||||
github.com/jessevdk/go-flags v1.5.0
|
||||
github.com/stretchr/testify v1.8.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/umputun/remark42/backend v1.10.1
|
||||
)
|
||||
|
||||
|
||||
@@ -60,11 +60,13 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||
golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw=
|
||||
|
||||
@@ -338,6 +338,6 @@ func TestRPC_closeHndl(t *testing.T) {
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
err := re.Close()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, re.Close())
|
||||
assert.NoError(t, re.Close(), "second call should not result in panic or errors")
|
||||
}
|
||||
|
||||
+45
-11
@@ -96,16 +96,17 @@ type ServerCommand struct {
|
||||
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of cookie"`
|
||||
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint
|
||||
|
||||
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
|
||||
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
|
||||
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
|
||||
Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
|
||||
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
|
||||
Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"Twitter OAuth"`
|
||||
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"`
|
||||
Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"`
|
||||
Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"`
|
||||
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"`
|
||||
Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
|
||||
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
|
||||
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
|
||||
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
|
||||
Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
|
||||
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
|
||||
Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"Twitter OAuth"`
|
||||
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"`
|
||||
Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"`
|
||||
Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"`
|
||||
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"`
|
||||
Email struct {
|
||||
Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"`
|
||||
From string `long:"from" env:"FROM" description:"from email address"`
|
||||
@@ -133,6 +134,14 @@ type ImageProxyGroup struct {
|
||||
CacheExternal bool `long:"cache-external" env:"CACHE_EXTERNAL" description:"enable caching for external images"`
|
||||
}
|
||||
|
||||
// AppleGroup defines options for Apple auth params
|
||||
type AppleGroup struct {
|
||||
CID string `long:"cid" env:"CID" description:"Apple client ID"`
|
||||
TID string `long:"tid" env:"TID" description:"Apple service ID"`
|
||||
KID string `long:"kid" env:"KID" description:"Private key ID"`
|
||||
PrivateKeyFilePath string `long:"private-key-filepath" env:"PRIVATE_KEY_FILEPATH" description:"Private key file location" default:"/srv/var/apple.p8"`
|
||||
}
|
||||
|
||||
// AuthGroup defines options group for auth params
|
||||
type AuthGroup struct {
|
||||
CID string `long:"cid" env:"CID" description:"OAuth client ID"`
|
||||
@@ -197,7 +206,7 @@ type AdminGroup struct {
|
||||
Admins []string `long:"id" env:"ID" description:"admin(s) ids" env-delim:","`
|
||||
Email []string `long:"email" env:"EMAIL" description:"admin emails" env-delim:","`
|
||||
} `group:"shared" namespace:"shared" env-namespace:"SHARED"`
|
||||
RPC RPCGroup `group:"rpc" namespace:"rpc" env-namespace:"RPC"`
|
||||
RPC AdminRPCGroup `group:"rpc" namespace:"rpc" env-namespace:"RPC"`
|
||||
}
|
||||
|
||||
// TelegramGroup defines token for Telegram used in notify and auth modules
|
||||
@@ -265,6 +274,12 @@ type RPCGroup struct {
|
||||
AuthPassword string `long:"auth_passwd" env:"AUTH_PASSWD" description:"basic auth user password"`
|
||||
}
|
||||
|
||||
// AdminRPCGroup defines options for remote admin store
|
||||
type AdminRPCGroup struct {
|
||||
RPCGroup
|
||||
SecretPerSite bool `long:"secret_per_site" env:"SECRET_PER_SITE" description:"enable JWT secret retrieval per aud, which is site_id in this case"`
|
||||
}
|
||||
|
||||
// LoadingCache defines interface for caching
|
||||
type LoadingCache interface {
|
||||
Get(key cache.Key, fn func() ([]byte, error)) (data []byte, err error) // load from cache if found or put to cache and return
|
||||
@@ -515,6 +530,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
err = s.addAuthProviders(authenticator)
|
||||
if err != nil {
|
||||
_ = dataService.Close()
|
||||
_ = authRefreshCache.Close()
|
||||
return nil, fmt.Errorf("failed to make authenticator: %w", err)
|
||||
}
|
||||
|
||||
@@ -554,6 +570,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
sslConfig, err := s.makeSSLConfig()
|
||||
if err != nil {
|
||||
_ = dataService.Close()
|
||||
_ = authRefreshCache.Close()
|
||||
return nil, fmt.Errorf("failed to make config of ssl server params: %w", err)
|
||||
}
|
||||
|
||||
@@ -594,6 +611,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
da, errDevAuth := authenticator.DevAuth()
|
||||
if errDevAuth != nil {
|
||||
_ = dataService.Close()
|
||||
_ = authRefreshCache.Close()
|
||||
return nil, fmt.Errorf("can't make dev oauth2 server: %w", errDevAuth)
|
||||
}
|
||||
devAuth = da
|
||||
@@ -829,12 +847,27 @@ func (s *ServerCommand) makeCache() (LoadingCache, error) {
|
||||
return nil, fmt.Errorf("unsupported cache type %s", s.Cache.Type)
|
||||
}
|
||||
|
||||
//nolint:gocyclo // simple code but many if checks
|
||||
func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
providersCount := 0
|
||||
if s.Auth.Telegram {
|
||||
providersCount++
|
||||
}
|
||||
|
||||
if s.Auth.Apple.CID != "" && s.Auth.Apple.TID != "" && s.Auth.Apple.KID != "" {
|
||||
err := authenticator.AddAppleProvider(
|
||||
provider.AppleConfig{
|
||||
ClientID: s.Auth.Apple.CID,
|
||||
TeamID: s.Auth.Apple.TID,
|
||||
KeyID: s.Auth.Apple.KID,
|
||||
},
|
||||
provider.LoadApplePrivateKeyFromFile(s.Auth.Apple.PrivateKeyFilePath),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" {
|
||||
authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC)
|
||||
providersCount++
|
||||
@@ -1157,6 +1190,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
||||
Logger: log.Default(),
|
||||
RefreshCache: authRefreshCache,
|
||||
UseGravatar: true,
|
||||
AudSecrets: s.Admin.RPC.SecretPerSite,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestServerApp_DevMode(t *testing.T) {
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 9+1, len(providers), "extra auth provider")
|
||||
require.Equal(t, 10+1, len(providers), "extra auth provider")
|
||||
assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider")
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
@@ -107,7 +107,7 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 9+1, len(providers), "extra auth provider for anon")
|
||||
require.Equal(t, 10+1, len(providers), "extra auth provider for anon")
|
||||
assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider")
|
||||
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
@@ -290,7 +290,8 @@ func TestServerApp_WithRemote(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
|
||||
"--store.type=rpc", "--store.rpc.api=http://127.0.0.1",
|
||||
"--port=" + strconv.Itoa(port), "--admin.type=rpc", "--admin.rpc.api=http://127.0.0.1", "--avatar.fs.path=/tmp"})
|
||||
"--port=" + strconv.Itoa(port), "--avatar.fs.path=/tmp",
|
||||
"--admin.type=rpc", "--admin.rpc.secret_per_site", "--admin.rpc.api=http://127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid"
|
||||
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
|
||||
@@ -351,6 +352,7 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
|
||||
t.Log(err)
|
||||
|
||||
// wrong store type
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
|
||||
@@ -374,6 +376,19 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
"problem subscribing to channel remark42-cache on address wrong_address: "+
|
||||
"dial tcp: address wrong_address: missing port in address")
|
||||
t.Log(err)
|
||||
|
||||
// wrong apple private key type
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p = flags.NewParser(&opts, flags.Default)
|
||||
_, err = p.ParseArgs([]string{"--auth.apple.cid=123", "--auth.apple.tid=123",
|
||||
"--auth.apple.kid=123", "--auth.apple.private-key-filepath=testdata/apple-bad.p8"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err,
|
||||
"failed to make authenticator: an AppleProvider creating failed: "+
|
||||
"provided private key is not ECDSA")
|
||||
t.Log(err)
|
||||
}
|
||||
|
||||
func TestServerApp_Shutdown(t *testing.T) {
|
||||
@@ -758,6 +773,8 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
||||
cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp/remark42_test", "fs", "/tmp/remark42_test", "/tmp/remark42_test"
|
||||
cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port)
|
||||
cmd.Store.Bolt.Timeout = 10 * time.Second
|
||||
cmd.Auth.Apple.CID, cmd.Auth.Apple.KID, cmd.Auth.Apple.TID = "cid", "kid", "tid"
|
||||
cmd.Auth.Apple.PrivateKeyFilePath = "testdata/apple.p8"
|
||||
cmd.Auth.Github.CSEC, cmd.Auth.Github.CID = "csec", "cid"
|
||||
cmd.Auth.Google.CSEC, cmd.Auth.Google.CID = "csec", "cid"
|
||||
cmd.Auth.Facebook.CSEC, cmd.Auth.Facebook.CID = "csec", "cid"
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP
|
||||
HRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t
|
||||
KvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE
|
||||
nUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf
|
||||
JB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4
|
||||
f63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK
|
||||
I8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx
|
||||
TjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+
|
||||
oAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6
|
||||
HD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG
|
||||
GcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP
|
||||
iukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF
|
||||
6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH
|
||||
x6XroMXsmbnsEw==
|
||||
-----END PRIVATE KEY-----
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgGH2MylyZjjRdauTk
|
||||
xxXW6p8VSHqIeVRRKSJPg1xn6+KgCgYIKoZIzj0DAQehRANCAAS/mNzQ7aBbIBr3
|
||||
DiHiJGIDEzi6+q3mmyhH6ZWQWFdFei2qgdyM1V6qtRPVq+yHBNSBebbR4noE/IYO
|
||||
hMdWYrKn
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -83,7 +83,7 @@ func (ab AutoBackup) removeOldBackupFiles() {
|
||||
backFiles = append(backFiles, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(backFiles, func(i int, j int) bool { return backFiles[i].Name() < backFiles[j].Name() })
|
||||
sort.Slice(backFiles, func(i, j int) bool { return backFiles[i].Name() < backFiles[j].Name() })
|
||||
|
||||
if len(backFiles) > ab.KeepMax {
|
||||
for i := 0; i < len(backFiles)-ab.KeepMax; i++ {
|
||||
|
||||
@@ -38,7 +38,7 @@ type MapperMaker func(reader io.Reader) (Mapper, error)
|
||||
type Store interface {
|
||||
Create(comment store.Comment) (commentID string, err error)
|
||||
Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error)
|
||||
List(siteID string, limit int, skip int) ([]store.PostInfo, error)
|
||||
List(siteID string, limit, skip int) ([]store.PostInfo, error)
|
||||
DeleteAll(siteID string) error
|
||||
Metas(siteID string) (umetas []service.UserMetaData, pmetas []service.PostMetaData, err error)
|
||||
SetMetas(siteID string, umetas []service.UserMetaData, pmetas []service.PostMetaData) error
|
||||
|
||||
@@ -34,8 +34,8 @@ type Destination interface {
|
||||
// Store defines the minimal interface accessing stored comments used by notifier
|
||||
type Store interface {
|
||||
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
|
||||
GetUserEmail(siteID string, userID string) (string, error)
|
||||
GetUserTelegram(siteID string, userID string) (string, error)
|
||||
GetUserEmail(siteID, userID string) (string, error)
|
||||
GetUserTelegram(siteID, userID string) (string, error)
|
||||
}
|
||||
|
||||
// used for email and telegram retrieval from user details
|
||||
@@ -143,6 +143,12 @@ func (s *Service) SubmitVerification(req VerificationRequest) {
|
||||
// Close queue channel and wait for completion
|
||||
func (s *Service) Close() {
|
||||
if s.queue != nil {
|
||||
// don't panic in case service is already closed
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
log.Print("[DEBUG] close notifier")
|
||||
close(s.queue)
|
||||
close(s.verificationQueue)
|
||||
|
||||
@@ -21,6 +21,8 @@ func TestService_NoDestinations(t *testing.T) {
|
||||
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
||||
s.Close()
|
||||
// second call should not result in panic
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestService_WithDestinations(t *testing.T) {
|
||||
|
||||
@@ -29,15 +29,15 @@ type admin struct {
|
||||
|
||||
type adminStore interface {
|
||||
Delete(locator store.Locator, commentID string, mode store.DeleteMode) error
|
||||
DeleteUser(siteID string, userID string, mode store.DeleteMode) error
|
||||
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
|
||||
DeleteUser(siteID, userID string, mode store.DeleteMode) error
|
||||
DeleteUserDetail(siteID, userID string, detail engine.UserDetail) error
|
||||
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
|
||||
IsBlocked(siteID string, userID string) bool
|
||||
SetBlock(siteID string, userID string, status bool, ttl time.Duration) error
|
||||
IsBlocked(siteID, userID string) bool
|
||||
SetBlock(siteID, userID string, status bool, ttl time.Duration) error
|
||||
BlockedUsers(siteID string) ([]store.BlockedUser, error)
|
||||
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
|
||||
SetTitle(locator store.Locator, commentID string) (comment store.Comment, err error)
|
||||
SetVerified(siteID string, userID string, status bool) error
|
||||
SetVerified(siteID, userID string, status bool) error
|
||||
SetReadOnly(locator store.Locator, status bool) error
|
||||
SetPin(locator store.Locator, commentID string, status bool) error
|
||||
}
|
||||
|
||||
@@ -651,7 +651,7 @@ func subscribersOnly(enable bool) func(http.Handler) http.Handler {
|
||||
func validEmailAuth() func(http.Handler) http.Handler {
|
||||
|
||||
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
|
||||
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_-]{1,64}$`)
|
||||
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
|
||||
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -58,15 +58,15 @@ type privStore interface {
|
||||
Vote(req service.VoteReq) (comment store.Comment, err error)
|
||||
Get(locator store.Locator, commentID string, user store.User) (store.Comment, error)
|
||||
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
|
||||
GetUserEmail(siteID string, userID string) (string, error)
|
||||
SetUserEmail(siteID string, userID string, value string) (string, error)
|
||||
GetUserTelegram(siteID string, userID string) (string, error)
|
||||
SetUserTelegram(siteID string, userID string, value string) (string, error)
|
||||
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
|
||||
GetUserEmail(siteID, userID string) (string, error)
|
||||
SetUserEmail(siteID, userID, value string) (string, error)
|
||||
GetUserTelegram(siteID, userID string) (string, error)
|
||||
SetUserTelegram(siteID, userID, value string) (string, error)
|
||||
DeleteUserDetail(siteID, userID string, detail engine.UserDetail) error
|
||||
ValidateComment(c *store.Comment) error
|
||||
IsVerified(siteID string, userID string) bool
|
||||
IsVerified(siteID, userID string) bool
|
||||
IsReadOnly(locator store.Locator) bool
|
||||
IsBlocked(siteID string, userID string) bool
|
||||
IsBlocked(siteID, userID string) bool
|
||||
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}{}
|
||||
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &edit); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't read comment details from body", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -307,28 +307,59 @@ func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
|
||||
// GET /email/subscribe?site=siteID&address=someone@example.com
|
||||
// In case user is logged in with the same email, and auto_confirm is true, confirm it right away.
|
||||
// In case of quick confirmation, "updated" is set to true, otherwise - to false.
|
||||
// POST /email/subscribe with site and address in json body
|
||||
//
|
||||
//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON
|
||||
func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
address := r.URL.Query().Get("address")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
if address == "" {
|
||||
|
||||
subscribe := struct {
|
||||
Site string
|
||||
Address string
|
||||
autoConfirm bool
|
||||
}{autoConfirm: true}
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &subscribe); err != nil {
|
||||
if err != io.EOF {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
// old behavior fallback, reading from the query params. Auto confirm is false in this case.
|
||||
subscribe.Address = r.URL.Query().Get("address")
|
||||
subscribe.Site = r.URL.Query().Get("site")
|
||||
subscribe.autoConfirm = false
|
||||
}
|
||||
|
||||
if subscribe.Address == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest,
|
||||
fmt.Errorf("missing parameter"), "address parameter is required", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
existingAddress, err := s.dataService.GetUserEmail(siteID, user.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
||||
existingAddress, getErr := s.dataService.GetUserEmail(subscribe.Site, user.ID)
|
||||
if getErr != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, getErr)
|
||||
}
|
||||
if address == existingAddress {
|
||||
if subscribe.Address == existingAddress {
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict,
|
||||
fmt.Errorf("already verified"), "email address is already verified for this user", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// in case the user logged in with the same email as they try to subscribe with, confirm it right away
|
||||
// this behavior is different from the previous one and is hidden behind the autoConfirm flag,
|
||||
// which is true for the new API, and false for the old one
|
||||
//
|
||||
// nolint:gosec // this is not used for security purposes
|
||||
if subscribe.autoConfirm &&
|
||||
strings.HasPrefix(user.ID, "email_") &&
|
||||
strings.TrimPrefix(user.ID, "email_") == token.HashID(sha1.New(), subscribe.Address) {
|
||||
s.setEmail(w, r, user.ID, subscribe.Site, subscribe.Address)
|
||||
return
|
||||
}
|
||||
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: user.ID + "::" + address},
|
||||
Handshake: &token.Handshake{ID: user.ID + "::" + subscribe.Address},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: r.URL.Query().Get("site"),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||||
@@ -345,14 +376,14 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
s.notifyService.SubmitVerification(
|
||||
notify.VerificationRequest{
|
||||
SiteID: siteID,
|
||||
SiteID: subscribe.Site,
|
||||
User: user.Name,
|
||||
Email: address,
|
||||
Email: subscribe.Address,
|
||||
Token: tkn,
|
||||
},
|
||||
)
|
||||
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": subscribe.Address, "updated": false})
|
||||
}
|
||||
|
||||
// telegramSubscribeCtrl generates and verifies telegram notification request
|
||||
@@ -416,17 +447,29 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
|
||||
// PUT /email/confirm?site=siteID&tkn=jwt
|
||||
// POST /email/confirm with site and token in json body
|
||||
func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
tkn := r.URL.Query().Get("tkn")
|
||||
if tkn == "" {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
|
||||
confirm := struct {
|
||||
Site string
|
||||
Token string
|
||||
}{}
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &confirm); err != nil {
|
||||
if err != io.EOF {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
// old behavior fallback, reading from the query params
|
||||
confirm.Token = r.URL.Query().Get("tkn")
|
||||
confirm.Site = r.URL.Query().Get("site")
|
||||
}
|
||||
|
||||
if confirm.Token == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("missing parameter"), "token parameter is required", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
user := rest.MustGetUserInfo(r)
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
confClaims, err := s.authenticator.TokenService().Parse(tkn)
|
||||
confClaims, err := s.authenticator.TokenService().Parse(confirm.Token)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||
return
|
||||
@@ -444,17 +487,20 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
address := elems[1]
|
||||
s.setEmail(w, r, user.ID, confirm.Site, address)
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] set email for user %s", user.ID)
|
||||
func (s *private) setEmail(w http.ResponseWriter, r *http.Request, userID, siteID, address string) {
|
||||
log.Printf("[DEBUG] set email for user %s", userID)
|
||||
|
||||
val, err := s.dataService.SetUserEmail(siteID, user.ID, address)
|
||||
val, err := s.dataService.SetUserEmail(siteID, userID, address)
|
||||
if err != nil {
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code)
|
||||
return
|
||||
}
|
||||
|
||||
// update User.Email from the token
|
||||
// update User.Email field
|
||||
claims, _, err := s.authenticator.TokenService().Get(r)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||
|
||||
@@ -728,19 +728,25 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
responseCode int
|
||||
noAuth bool
|
||||
cookieEmail string
|
||||
body string
|
||||
}{
|
||||
{description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
||||
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest},
|
||||
{description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send email confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send email confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||
{description: "set user email, token not set", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||
{description: "set user email, token not set, old query param", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send email confirmation without address", url: "/api/v1/email/subscribe", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||
{description: "send email confirmation without address, old query param", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send email confirmation", url: "/api/v1/email/subscribe", method: http.MethodPost, responseCode: http.StatusOK, body: `{"site":"remark42","address":"good@example.com"}`},
|
||||
{description: "send email confirmation, old query param", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user email, token is good", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||
{description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
|
||||
{description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
{description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||
{description: "set user email, token is good", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||
{description: "unsubscribe user, no token", url: "/email/unsubscribe.html?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "unsubscribe user, wrong token", url: "/email/unsubscribe.html?site=remark42&tkn=jwt", method: http.MethodGet, responseCode: http.StatusForbidden},
|
||||
{description: "unsubscribe user, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK},
|
||||
@@ -761,7 +767,11 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
for _, x := range testData {
|
||||
x := x
|
||||
t.Run(x.description, func(t *testing.T) {
|
||||
req, err := http.NewRequest(x.method, ts.URL+x.url, http.NoBody)
|
||||
reqBody := io.NopCloser(strings.NewReader(x.body))
|
||||
if x.body == "" {
|
||||
reqBody = http.NoBody
|
||||
}
|
||||
req, err := http.NewRequest(x.method, ts.URL+x.url, reqBody)
|
||||
require.NoError(t, err)
|
||||
if !x.noAuth {
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
@@ -837,7 +847,11 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
assert.Empty(t, mockDestination.Get()[1].Emails)
|
||||
|
||||
// send confirmation token for email
|
||||
req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com", http.NoBody)
|
||||
req, err = http.NewRequest(
|
||||
http.MethodPost,
|
||||
ts.URL+"/api/v1/email/subscribe",
|
||||
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
@@ -852,8 +866,31 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email)
|
||||
verificationToken := mockDestination.GetVerify()[0].Token
|
||||
|
||||
// get user information to verify lack of the subscription
|
||||
req, err = http.NewRequest(
|
||||
http.MethodGet,
|
||||
ts.URL+"/api/v1/user?site=remark42",
|
||||
http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
var clearUser store.User
|
||||
err = json.Unmarshal(body, &clearUser)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, store.User{Name: "developer one", ID: "dev", EmailSubscription: false,
|
||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, clearUser)
|
||||
|
||||
// verify email
|
||||
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), http.NoBody)
|
||||
req, err = http.NewRequest(
|
||||
http.MethodPost,
|
||||
ts.URL+"/api/v1/email/confirm",
|
||||
io.NopCloser(strings.NewReader(fmt.Sprintf(`{"site": "remark42", "token": %q}`, verificationToken))),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
@@ -864,7 +901,10 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
|
||||
// get user information to verify the subscription
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/user?site=remark42", http.NoBody)
|
||||
req, err = http.NewRequest(
|
||||
http.MethodGet,
|
||||
ts.URL+"/api/v1/user?site=remark42",
|
||||
http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err = client.Do(req)
|
||||
@@ -873,11 +913,11 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
var user store.User
|
||||
err = json.Unmarshal(body, &user)
|
||||
var subscribedUser store.User
|
||||
err = json.Unmarshal(body, &subscribedUser)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, store.User{Name: "developer one", ID: "dev", EmailSubscription: true,
|
||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user)
|
||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, subscribedUser)
|
||||
|
||||
// create child comment from another user, email notification expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
||||
@@ -928,6 +968,80 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 4, len(mockDestination.Get()))
|
||||
assert.Empty(t, mockDestination.Get()[3].Emails)
|
||||
|
||||
// confirm email via subscribe call with query params, old behavior, email notification is expected
|
||||
req, err = http.NewRequest(
|
||||
http.MethodPost,
|
||||
ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com",
|
||||
http.NoBody,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", emailUserToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 2, len(mockDestination.GetVerify()), "verification email was sent")
|
||||
|
||||
// get email user information to verify there is no subscription yet
|
||||
req, err = http.NewRequest(
|
||||
http.MethodGet,
|
||||
ts.URL+"/api/v1/user?site=remark42",
|
||||
http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", emailUserToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
var unsubscribedEmailUser store.User
|
||||
err = json.Unmarshal(body, &unsubscribedEmailUser)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, store.User{Name: "good@example.com test user", ID: "email_f5dfe9d2e6bd75fc74ea5fabf273b45b5baeb195", EmailSubscription: false,
|
||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, unsubscribedEmailUser)
|
||||
|
||||
// confirm email via subscribe call, no email notification is expected
|
||||
req, err = http.NewRequest(
|
||||
http.MethodPost,
|
||||
ts.URL+"/api/v1/email/subscribe",
|
||||
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", emailUserToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 2, len(mockDestination.GetVerify()), "no new verification email was sent")
|
||||
|
||||
// get email user information to verify the subscription happened without the confirmation call
|
||||
req, err = http.NewRequest(
|
||||
http.MethodGet,
|
||||
ts.URL+"/api/v1/user?site=remark42",
|
||||
http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", emailUserToken)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
var subscribedEmailUser store.User
|
||||
err = json.Unmarshal(body, &subscribedEmailUser)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, store.User{Name: "good@example.com test user", ID: "email_f5dfe9d2e6bd75fc74ea5fabf273b45b5baeb195", EmailSubscription: true,
|
||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, subscribedEmailUser)
|
||||
}
|
||||
|
||||
func TestRest_TelegramNotification(t *testing.T) {
|
||||
|
||||
@@ -40,7 +40,7 @@ type pubStore interface {
|
||||
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
|
||||
UserCount(siteID, userID string) (int, error)
|
||||
Count(locator store.Locator) (int, error)
|
||||
List(siteID string, limit int, skip int) ([]store.PostInfo, error)
|
||||
List(siteID string, limit, skip int) ([]store.PostInfo, error)
|
||||
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
|
||||
|
||||
ValidateComment(c *store.Comment) error
|
||||
|
||||
@@ -37,10 +37,15 @@ import (
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
// To generate a token, enter one of the tokens here into https://jwt.io, change the secret to one you're using in your test
|
||||
// ("secret" in case of startupT), and alter the fields you want to be changed.
|
||||
|
||||
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg`
|
||||
|
||||
var anonToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImFub255bW91cyB0ZXN0IHVzZXIiLCJpZCI6ImFub255bW91c190ZXN0X3VzZXIiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoiYW5vbkBleGFtcGxlLmNvbSJ9fQ.gAae2WMxZNZE5ebVboptPEyQ7Nk6EQxciNnGJ_mPOuU`
|
||||
|
||||
var emailUserToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6Imdvb2RAZXhhbXBsZS5jb20gdGVzdCB1c2VyIiwiaWQiOiJlbWFpbF9mNWRmZTlkMmU2YmQ3NWZjNzRlYTVmYWJmMjczYjQ1YjViYWViMTk1IiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Imdvb2RAZXhhbXBsZS5jb20ifX0.vH2HN1JpuXL8okTJq1A-zGHQ-l2ILcwxvDDEmu2zwks`
|
||||
|
||||
var devTokenBadAud = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0Ml9iYWQiLCJleHAiOjM3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTIxODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJkZXYiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoibWVAZXhhbXBsZS5jb20ifX0.FuTTocVtcxr4VjpfIICvU2yOb3su28VkDzj94H9Q3xY`
|
||||
|
||||
var adminUmputunToken = `eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6MTk1NDU5Nzk4MCwianRpIjoiOTdhMmUwYWM0ZGM3ZDVmNjkyNmQ1ZTg2MjBhY2VmOWE0MGMwIiwiaWF0IjoxNDU0NTk3NjgwLCJpc3MiOiJyZW1hcms0MiIsInVzZXIiOnsibmFtZSI6IlVtcHV0dW4iLCJpZCI6ImdpdGh1Yl9lZjBmNzA2YTciLCJwaWN0dXJlIjoiaHR0cHM6Ly9yZW1hcms0Mi5yYWRpby10LmNvbS9hcGkvdjEvYXZhdGFyL2NiNDJmZjQ5M2FkZTY5NmQ4OGEzYTU5MGYxMzZhZTllMzRkZTdjMWIuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.dZiOjWHguo9f42XCMooMcv4EmYFzifl_-LEvPZHCtks`
|
||||
@@ -378,7 +383,7 @@ func Test_validEmailAuth(t *testing.T) {
|
||||
status int
|
||||
}{
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK},
|
||||
{"/auth/email/login?site=site-with-dash_and_underscore&address=umputun%example.com&user=someone", http.StatusOK},
|
||||
{"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden},
|
||||
|
||||
@@ -111,15 +111,11 @@ func (s *Rest) getRemarkHost() string {
|
||||
|
||||
func (s *Rest) makeTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
PreferServerCipherSuites: true,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
// tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
// tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
// tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
|
||||
},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{
|
||||
|
||||
@@ -78,5 +78,5 @@ func (s *StaticStore) Enabled(site string) (ok bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// OnEvent doesn nothing for StaticStore
|
||||
// OnEvent does nothing for StaticStore
|
||||
func (s *StaticStore) OnEvent(_ string, _ EventType) error { return nil }
|
||||
|
||||
@@ -118,6 +118,8 @@ func (c *Comment) SetDeleted(mode DeleteMode) {
|
||||
func (c *Comment) Sanitize() {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowAttrs("class").Matching(regexp.MustCompile("^chroma$")).OnElements("pre")
|
||||
// special case for embedding the quotes from Twitter
|
||||
p.AllowAttrs("class").Matching(regexp.MustCompile("^twitter-tweet$")).OnElements("blockquote")
|
||||
// this is list of <span> tag classes which could be produced by chroma code renderer
|
||||
// source: https://github.com/alecthomas/chroma/blob/cc2dd5b/types.go#L211-L307
|
||||
const codeSpanClassRegex = "^(bg|chroma|line|ln|lnt|hl|lntable|lntd|cl|w|err|x|k|kc" +
|
||||
|
||||
@@ -88,6 +88,10 @@ func TestComment_Sanitize(t *testing.T) {
|
||||
inp: Comment{Text: "blah blah", PostTitle: "<script>alert()</script>something"},
|
||||
out: Comment{Text: "blah blah", PostTitle: "something"},
|
||||
},
|
||||
{
|
||||
inp: Comment{Text: `<blockquote class="twitter-tweet"><p lang="es" dir="ltr">Silicon iMac Concept<a href="https://t.co/7ga95QxVXn">https://t.co/7ga95QxVXn</a> by <a href="https://twitter.com/marcsheep?ref_src=twsrc%5Etfw">@marcsheep</a> <a href="https://t.co/ULnVpG8w55">pic.twitter.com/ULnVpG8w55</a></p>— Andreas Storm (@avstorm) <a href="https://twitter.com/avstorm/status/1325693387798933504?ref_src=twsrc%5Etfw">November 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`, PostTitle: "Twitter quote"},
|
||||
out: Comment{Text: `<blockquote class="twitter-tweet"><p lang="es" dir="ltr">Silicon iMac Concept<a href="https://t.co/7ga95QxVXn" rel="nofollow">https://t.co/7ga95QxVXn</a> by <a href="https://twitter.com/marcsheep?ref_src=twsrc%5Etfw" rel="nofollow">@marcsheep</a> <a href="https://t.co/ULnVpG8w55" rel="nofollow">pic.twitter.com/ULnVpG8w55</a></p>— Andreas Storm (@avstorm) <a href="https://twitter.com/avstorm/status/1325693387798933504?ref_src=twsrc%5Etfw" rel="nofollow">November 9, 2020</a></blockquote> `, PostTitle: "Twitter quote"},
|
||||
},
|
||||
}
|
||||
|
||||
for n, tt := range tbl {
|
||||
|
||||
@@ -16,15 +16,15 @@ import (
|
||||
|
||||
// BoltDB implements store.Interface, represents multiple sites with multiplexing to different bolt dbs. Thread safe.
|
||||
// there are 6 types of top-level buckets:
|
||||
// - comments for post in "posts" top-level bucket. Each url (post) makes its own bucket and each k:v pair is commentID:comment
|
||||
// - history of all comments. They all in a single "last" bucket (per site) and key is defined by ref struct as ts+commentID
|
||||
// value is not full comment but a reference combined from post-url+commentID
|
||||
// - user to comment references in "users" bucket. It used to get comments for user. Key is userID and value
|
||||
// is a nested bucket named userID with kv as ts:reference
|
||||
// - users details in "user_details" bucket. Key is userID, value - UserDetailEntry
|
||||
// - blocking info sits in "block" bucket. Key is userID, value - ts
|
||||
// - counts per post to keep number of comments. Key is post url, value - count
|
||||
// - readonly per post to keep status of manually set RO posts. Key is post url, value - ts
|
||||
// - comments for post in "posts" top-level bucket. Each url (post) makes its own bucket and each k:v pair is commentID:comment
|
||||
// - history of all comments. They all in a single "last" bucket (per site) and key is defined by ref struct as ts+commentID
|
||||
// value is not full comment but a reference combined from post-url+commentID
|
||||
// - user to comment references in "users" bucket. It used to get comments for user. Key is userID and value
|
||||
// is a nested bucket named userID with kv as ts:reference
|
||||
// - users details in "user_details" bucket. Key is userID, value - UserDetailEntry
|
||||
// - blocking info sits in "block" bucket. Key is userID, value - ts
|
||||
// - counts per post to keep number of comments. Key is post url, value - count
|
||||
// - readonly per post to keep status of manually set RO posts. Key is post url, value - ts
|
||||
type BoltDB struct {
|
||||
dbs map[string]*bolt.DB
|
||||
}
|
||||
@@ -780,7 +780,7 @@ func (b *BoltDB) deleteUserDetail(bdb *bolt.DB, userID string, userDetail UserDe
|
||||
}
|
||||
|
||||
return bdb.Update(func(tx *bolt.Tx) error {
|
||||
// updated entry is not empty and we need to store it's updated copy
|
||||
// updated entry is not empty and we need to store its updated copy
|
||||
err := b.save(tx.Bucket([]byte(userDetailsBucketName)), userID, entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update detail %s for %s: %w", userDetail, userID, err)
|
||||
|
||||
@@ -911,6 +911,13 @@ func TestBoltDB_NewFailed(t *testing.T) {
|
||||
assert.EqualError(t, err, "failed to make boltdb for /tmp/no-such-place/tmp.db: open /tmp/no-such-place/tmp.db: no such file or directory")
|
||||
}
|
||||
|
||||
func TestBoltDB_DoubleClose(t *testing.T) {
|
||||
var b, teardown = prep(t)
|
||||
defer teardown()
|
||||
assert.NoError(t, b.Close())
|
||||
assert.NoError(t, b.Close(), "second call should not result in panic or errors")
|
||||
}
|
||||
|
||||
// makes new boltdb, put two records
|
||||
func prep(t *testing.T) (b *BoltDB, teardown func()) {
|
||||
_ = os.Remove(testDB)
|
||||
|
||||
@@ -186,11 +186,17 @@ func TestRemote_Delete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemote_Close(t *testing.T) {
|
||||
ts := testServer(t, `{"method":"store.close","id":1}`, `{}`)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(body), "{\"method\":\"store.close\",\"id\":")
|
||||
t.Logf("req: %s", string(body))
|
||||
_, _ = fmt.Fprint(w, "{}")
|
||||
}))
|
||||
defer ts.Close()
|
||||
c := RPC{Client: jrpc.Client{API: ts.URL, Client: http.Client{}}}
|
||||
err := c.Close()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, c.Close())
|
||||
assert.NoError(t, c.Close(), "second call should not result in panic or errors")
|
||||
}
|
||||
|
||||
func testServer(t *testing.T, req, resp string) *httptest.Server {
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestFsStore_Cleanup(t *testing.T) {
|
||||
svc, teardown := prepareImageTest(t)
|
||||
defer teardown()
|
||||
|
||||
save := func(file string, user string) (filePath string) {
|
||||
save := func(file, user string) (filePath string) {
|
||||
id := path.Join(user, file)
|
||||
err := svc.Save(id, gopherPNGBytes())
|
||||
require.NoError(t, err)
|
||||
@@ -227,7 +227,7 @@ func TestFsStore_Cleanup(t *testing.T) {
|
||||
_, err = os.Stat(img2)
|
||||
assert.Error(t, err, "no file on staging anymore")
|
||||
_, err = os.Stat(img3)
|
||||
assert.NoError(t, err, "third image is still on staging because it's cleanup timer was reset")
|
||||
assert.NoError(t, err, "third image is still on staging because its cleanup timer was reset")
|
||||
|
||||
err = svc.ResetCleanupTimer("unknown_image.png")
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -286,3 +286,11 @@ func TestCachedImgID(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "cached_images/"+Sha1Str("example.org")+"-"+Sha1Str(imgURL), img)
|
||||
}
|
||||
|
||||
func TestService_DoubleClose(t *testing.T) {
|
||||
store := StoreMock{}
|
||||
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
||||
svc.Close(context.TODO())
|
||||
// second call should not result in panic
|
||||
svc.Close(context.TODO())
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ func (s *DataStore) EditComment(locator store.Locator, commentID string, req Edi
|
||||
if e := s.AdminStore.OnEvent(comment.Locator.SiteID, admin.EvDelete); e != nil {
|
||||
log.Printf("[WARN] failed to send delete event, %s", e)
|
||||
}
|
||||
// clean up the comment and it's parent from cache, so that
|
||||
// clean up the comment and its parent from cache, so that
|
||||
// after cleaning up the child, parent won't be stuck non-deletable till cache expires
|
||||
if s.repliesCache.LoadingCache != nil {
|
||||
s.repliesCache.Delete(commentID)
|
||||
@@ -745,12 +745,12 @@ func (s *DataStore) Delete(locator store.Locator, commentID string, mode store.D
|
||||
if e := s.AdminStore.OnEvent(locator.SiteID, admin.EvDelete); e != nil {
|
||||
log.Printf("[WARN] failed to send delete event, %s", e)
|
||||
}
|
||||
// get comment to learn it's parent ID
|
||||
// get comment to learn its parent ID
|
||||
comment, err := s.Engine.Get(engine.GetRequest{Locator: locator, CommentID: commentID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// clean up the comment and it's parent from cache, so that
|
||||
// clean up the comment and its parent from cache, so that
|
||||
// after cleaning up the child, parent won't be stuck non-deletable till cache expires
|
||||
if s.repliesCache.LoadingCache != nil {
|
||||
s.repliesCache.Delete(commentID)
|
||||
|
||||
@@ -1638,6 +1638,30 @@ func Benchmark_ServiceCreate(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DoubleClose_Bolt(t *testing.T) {
|
||||
dbFile := fmt.Sprintf("%s/test-remark42-%d.db", os.TempDir(), rand.Intn(9999999999))
|
||||
defer func() { _ = os.Remove(dbFile) }()
|
||||
|
||||
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: dbFile, SiteID: "radio-t"})
|
||||
svc := DataStore{Engine: boltStore, EditDuration: 50 * time.Millisecond, AdminStore: admin.NewStaticKeyStore("secret 123")}
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, boltStore.Close())
|
||||
assert.NoError(t, boltStore.Close(), "second call should not result in panic or errors")
|
||||
assert.NoError(t, svc.Close())
|
||||
assert.NoError(t, svc.Close(), "second call should not result in panic or errors")
|
||||
}
|
||||
|
||||
func TestService_DoubleClose_Static(t *testing.T) {
|
||||
ks := admin.NewStaticKeyStore("secret 123")
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, AdminStore: ks,
|
||||
TitleExtractor: NewTitleExtractor(http.Client{Timeout: 5 * time.Second})}
|
||||
b.Close()
|
||||
// second call should not result in panic or errors
|
||||
b.Close()
|
||||
}
|
||||
|
||||
// makes new boltdb, put two records
|
||||
func prepStoreEngine(t *testing.T) (e engine.Interface, teardown func()) {
|
||||
testDBLoc, err := os.MkdirTemp("", "test_image_r42")
|
||||
|
||||
@@ -123,3 +123,10 @@ func TestTitle_GetFailed(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "hit once, errors cached")
|
||||
}
|
||||
|
||||
func TestTitle_DoubleClosed(t *testing.T) {
|
||||
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second})
|
||||
ex.Close()
|
||||
// second call should not result in panic
|
||||
ex.Close()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestMakeTree(t *testing.T) {
|
||||
loc := store.Locator{URL: "url", SiteID: "site"}
|
||||
ts := func(min int, sec int) time.Time { return time.Date(2017, 12, 25, 19, min, sec, 0, time.UTC) }
|
||||
ts := func(min, sec int) time.Time { return time.Date(2017, 12, 25, 19, min, sec, 0, time.UTC) }
|
||||
|
||||
// unsorted by purpose
|
||||
comments := []store.Comment{
|
||||
@@ -54,7 +54,7 @@ func TestMakeTree(t *testing.T) {
|
||||
|
||||
func TestMakeEmptySubtree(t *testing.T) {
|
||||
loc := store.Locator{URL: "url", SiteID: "site"}
|
||||
ts := func(min int, sec int) time.Time { return time.Date(2017, 12, 25, 19, min, sec, 0, time.UTC) }
|
||||
ts := func(min, sec int) time.Time { return time.Date(2017, 12, 25, 19, min, sec, 0, time.UTC) }
|
||||
|
||||
// unsorted by purpose
|
||||
comments := []store.Comment{
|
||||
|
||||
+5
-5
@@ -11,9 +11,9 @@ require (
|
||||
github.com/go-chi/chi/v5 v5.0.7
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/render v1.0.2
|
||||
github.com/go-pkgz/auth v1.20.0
|
||||
github.com/go-pkgz/auth v1.20.1-0.20230103203948-168bd5a101b7
|
||||
github.com/go-pkgz/jrpc v0.3.0
|
||||
github.com/go-pkgz/lcw v1.0.1
|
||||
github.com/go-pkgz/lcw v1.0.3-0.20221226231215-a66ea7c4aff7
|
||||
github.com/go-pkgz/lgr v0.10.4
|
||||
github.com/go-pkgz/notify v0.2.0
|
||||
github.com/go-pkgz/repeater v1.1.3
|
||||
@@ -29,7 +29,7 @@ require (
|
||||
github.com/rs/xid v1.4.0
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/stretchr/testify v1.8.0
|
||||
github.com/stretchr/testify v1.8.1
|
||||
go.etcd.io/bbolt v1.3.6
|
||||
go.uber.org/goleak v1.2.0
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
|
||||
@@ -48,7 +48,7 @@ require (
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.1 // indirect
|
||||
github.com/go-pkgz/email v0.4.0 // indirect
|
||||
github.com/go-pkgz/email v0.4.1 // indirect
|
||||
github.com/go-pkgz/expirable-cache v0.1.0 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
@@ -56,7 +56,7 @@ require (
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/hashicorp/golang-lru v0.6.0 // indirect
|
||||
github.com/klauspost/compress v1.15.2 // indirect
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 // indirect
|
||||
|
||||
+17
-13
@@ -69,8 +69,8 @@ github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE
|
||||
github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis/v2 v2.22.0 h1:lIHHiSkEyS1MkKHCHzN+0mWrA4YdbGdimE5iZ2sHSzo=
|
||||
github.com/alicebob/miniredis/v2 v2.22.0/go.mod h1:XNqvJdQJv5mSuVMc0ynneafpnL/zv52acZ6kqeS0t88=
|
||||
github.com/alicebob/miniredis/v2 v2.23.1 h1:jR6wZggBxwWygeXcdNyguCOCIjPsZyNUNlAkTx2fu0U=
|
||||
github.com/alicebob/miniredis/v2 v2.23.1/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
@@ -140,17 +140,19 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.1 h1:3vxp+cjLqDe1TbogbwtMyeHRHr1tD+ksrK7xNppYRDs=
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.1/go.mod h1:wk/2uLImWIa9VVQDgxz99H2GDbhmfi/9/Xr+GvkSUSQ=
|
||||
github.com/go-pkgz/auth v1.20.0 h1:9SHkolgv9+zOI9U+8IFXra0pl/H90oE4+ciJWtXoDl4=
|
||||
github.com/go-pkgz/auth v1.20.0/go.mod h1:jz0djN+4XoCyieuOzc+rB9l0RdqeuiXoQfE02yonLAI=
|
||||
github.com/go-pkgz/auth v1.20.1-0.20221226231300-65f433fba0f1 h1:MJA4rZAwjd+KpaR2PqrxeDPloNu9Wml1UVQjL2fOtVM=
|
||||
github.com/go-pkgz/auth v1.20.1-0.20221226231300-65f433fba0f1/go.mod h1:fG1CP4+LDPnebYeO1BAZg/euTQQ8cnGn+5ZrXvJfckA=
|
||||
github.com/go-pkgz/auth v1.20.1-0.20230103203948-168bd5a101b7 h1:ktKI3Y3UytkBLL1cOEzJmAi3nNKeaRGOzDj51Kgqp6M=
|
||||
github.com/go-pkgz/auth v1.20.1-0.20230103203948-168bd5a101b7/go.mod h1:fG1CP4+LDPnebYeO1BAZg/euTQQ8cnGn+5ZrXvJfckA=
|
||||
github.com/go-pkgz/email v0.3.1-0.20221002173339-19d25a20d99c/go.mod h1:TpnmSLkQW3FyICit2hn7WIhCUDrhCX6btzz5wS3wHRI=
|
||||
github.com/go-pkgz/email v0.4.0 h1:NiYCwKPR6sW8nJsOR3GyB9Yw1AQZdnY78Xnhd9SLHEs=
|
||||
github.com/go-pkgz/email v0.4.0/go.mod h1:TpnmSLkQW3FyICit2hn7WIhCUDrhCX6btzz5wS3wHRI=
|
||||
github.com/go-pkgz/email v0.4.1 h1:2vtP2gibsSzqhz6eD5DklSp11m657XEVf17fuXaxMvk=
|
||||
github.com/go-pkgz/email v0.4.1/go.mod h1:BdxglsQnymzhfdbnncEE72a6DrucZHy6I+42LK2jLEc=
|
||||
github.com/go-pkgz/expirable-cache v0.1.0 h1:3bw0m8vlTK8qlwz5KXuygNBTkiKRTPrAGXU0Ej2AC1g=
|
||||
github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs=
|
||||
github.com/go-pkgz/jrpc v0.3.0 h1:Fls38KqPsHzvp0FWfivr6cGnncC+iFBodHBqvUPY+0U=
|
||||
github.com/go-pkgz/jrpc v0.3.0/go.mod h1:MFtKs75JESiSqVicsQkgN2iDFFuCd3gVT1/vKiwRi00=
|
||||
github.com/go-pkgz/lcw v1.0.1 h1:svYC6LIyzRaHF3TwJ8GCS+2RkJreBfaFjoeS+UYwJBc=
|
||||
github.com/go-pkgz/lcw v1.0.1/go.mod h1:CPJJzunpmGToOtD0Ga82TV152eL69sYEIIPcy9fbxlU=
|
||||
github.com/go-pkgz/lcw v1.0.3-0.20221226231215-a66ea7c4aff7 h1:PJ1JEt2G0Dn7OUkLEgbNIrABRcbkCPM8v58BIBOSkm8=
|
||||
github.com/go-pkgz/lcw v1.0.3-0.20221226231215-a66ea7c4aff7/go.mod h1:adhOCEhc8G6+Bd992MMp0h796aBxZ/b4feUGU5twkRU=
|
||||
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw=
|
||||
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0=
|
||||
github.com/go-pkgz/notify v0.2.0 h1:mxHjcLc3goT+k1qnBPJ06PpNuVUDcu21Xy+6hEo4IaU=
|
||||
@@ -270,8 +272,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
|
||||
github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
@@ -343,6 +345,7 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
@@ -350,8 +353,9 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E=
|
||||
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
|
||||
github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo=
|
||||
@@ -403,8 +407,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/gopher-lua v0.0.0-20210529063254-f4c35e4016d9 h1:k/gmLsJDWwWqbLCur2yWnJzwQEKRcAHXo6seXGuSwWw=
|
||||
github.com/yuin/gopher-lua v0.0.0-20210529063254-f4c35e4016d9/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA=
|
||||
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ=
|
||||
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||
go.mongodb.org/mongo-driver v1.10.2 h1:4Wk3cnqOrQCn0P92L3/mmurMxzdvWWs5J9jinAVKD+k=
|
||||
|
||||
+17
-2
@@ -119,12 +119,27 @@ GET {{host}}/api/v1/rss/reply?site={{site}}&user={{user}}
|
||||
GET {{host}}/api/v1/avatar/blah
|
||||
|
||||
### send confirmation token for current user to specified email. auth token for dev user for secret=12345.
|
||||
POST {{host}}/api/v1/email/subscribe?site={{site}}&address={{email}}
|
||||
### in case the user logged in with the same email, it will be confirmed right away with "updated" set to "true" in the response,
|
||||
### and no email will be sent.
|
||||
POST {{host}}/api/v1/email/subscribe
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"site": "{{site}}",
|
||||
"address": "{{email}}"
|
||||
}
|
||||
|
||||
|
||||
### add email for notifications for current user via token from email. auth token for dev user for secret=12345.
|
||||
POST {{host}}/api/v1/email/confirm?site={{site}}&tkn={{token}}
|
||||
POST {{host}}/api/v1/email/confirm
|
||||
X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"site": "{{site}}",
|
||||
"token": "{{token}}"
|
||||
}
|
||||
|
||||
### get current user email. auth token for dev user for secret=12345.
|
||||
GET {{host}}/api/v1/email?site={{site}}
|
||||
|
||||
+5
-5
@@ -29,17 +29,13 @@ linters:
|
||||
- revive
|
||||
- govet
|
||||
- unconvert
|
||||
- megacheck
|
||||
- structcheck
|
||||
- unused
|
||||
- gas
|
||||
- gocyclo
|
||||
- misspell
|
||||
- unparam
|
||||
- varcheck
|
||||
- deadcode
|
||||
- typecheck
|
||||
- ineffassign
|
||||
- varcheck
|
||||
- stylecheck
|
||||
- gochecknoinits
|
||||
- exportloopref
|
||||
@@ -67,5 +63,9 @@ issues:
|
||||
- text: "Use of weak cryptographic primitive"
|
||||
linters:
|
||||
- gosec
|
||||
- path: _test\.go
|
||||
text: "Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server"
|
||||
linters:
|
||||
- gosec
|
||||
|
||||
exclude-use-default: false
|
||||
|
||||
+4
-4
@@ -153,10 +153,6 @@ Such provider acts like any other, i.e. will be registered as `/auth/local/login
|
||||
|
||||
The API for this provider supports both GET and POST requests:
|
||||
|
||||
* GET request with user credentials provided as query params:
|
||||
```
|
||||
GET /auth/<name>/login?user=<user>&passwd=<password>&aud=<site_id>&session=[1|0]
|
||||
```
|
||||
* POST request could be encoded as application/x-www-form-urlencoded or application/json:
|
||||
```
|
||||
POST /auth/<name>/login?session=[1|0]
|
||||
@@ -172,6 +168,10 @@ The API for this provider supports both GET and POST requests:
|
||||
"aud": "bar",
|
||||
}
|
||||
```
|
||||
* GET request with user credentials provided as query params, but be aware that [the https query string is not secure](https://stackoverflow.com/a/323286/633961):
|
||||
```
|
||||
GET /auth/<name>/login?user=<user>&passwd=<password>&aud=<site_id>&session=[1|0]
|
||||
```
|
||||
|
||||
_note: password parameter doesn't have to be naked/real password and can be any kind of password hash prepared by caller._
|
||||
|
||||
|
||||
+1
-2
@@ -59,7 +59,6 @@ func (gf *GridFS) Get(avatar string) (reader io.ReadCloser, size int, err error)
|
||||
return io.NopCloser(buf), int(sz), nil
|
||||
}
|
||||
|
||||
//
|
||||
// ID returns a fingerprint of the avatar content. Uses MD5 because gridfs provides it directly
|
||||
func (gf *GridFS) ID(avatar string) (id string) {
|
||||
|
||||
@@ -143,7 +142,7 @@ func (gf *GridFS) List() (ids []string, err error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Close gridfs does nothing but satisfies interface
|
||||
// Close gridfs store
|
||||
func (gf *GridFS) Close() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gf.timeout)
|
||||
defer cancel()
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ func (fs *LocalFS) List() (ids []string, err error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Close gridfs does nothing but satisfies interface
|
||||
// Close LocalFS does nothing but satisfies interface
|
||||
func (fs *LocalFS) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-1
@@ -208,7 +208,11 @@ func (ah *AppleHandler) initPrivateKey() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ah.conf.publicKey = ah.conf.privateKey.(*ecdsa.PrivateKey).Public()
|
||||
publicKey, ok := ah.conf.privateKey.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("provided private key is not ECDSA")
|
||||
}
|
||||
ah.conf.publicKey = publicKey.Public()
|
||||
ah.conf.clientSecret, err = ah.createClientSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+2
-1
@@ -88,7 +88,8 @@ func (c *CustomServer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
c.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", port),
|
||||
Addr: fmt.Sprintf(":%s", port),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/authorize"):
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ func (d *DevAuthServer) Run(ctx context.Context) { // nolint (gocyclo)
|
||||
}
|
||||
|
||||
d.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", d.Provider.Port),
|
||||
Addr: fmt.Sprintf(":%d", d.Provider.Port),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
d.Logf("[DEBUG] dev oauth request %s %s %+v", r.Method, r.URL, r.Header)
|
||||
switch {
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ func (em *Sender) client() (c *smtp.Client, err error) {
|
||||
}
|
||||
|
||||
if em.tls {
|
||||
conn, e := tls.Dial("tcp", srvAddress, tlsConf)
|
||||
conn, e := tls.DialWithDialer(&net.Dialer{Timeout: em.timeOut}, "tcp", srvAddress, tlsConf)
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("failed to dial smtp tls to %s: %w", srvAddress, e)
|
||||
}
|
||||
|
||||
+1
-5
@@ -26,18 +26,14 @@ linters:
|
||||
- revive
|
||||
- govet
|
||||
- unconvert
|
||||
- megacheck
|
||||
- structcheck
|
||||
- gas
|
||||
- gocyclo
|
||||
- dupl
|
||||
- misspell
|
||||
- unparam
|
||||
- varcheck
|
||||
- deadcode
|
||||
- unused
|
||||
- typecheck
|
||||
- ineffassign
|
||||
- varcheck
|
||||
- stylecheck
|
||||
- gochecknoinits
|
||||
- exportloopref
|
||||
|
||||
+23
-16
@@ -30,22 +30,30 @@ Main features:
|
||||
## Usage
|
||||
|
||||
```go
|
||||
cache, err := lcw.NewLruCache(lcw.MaxKeys(500), lcw.MaxCacheSize(65536), lcw.MaxValSize(200), lcw.MaxKeySize(32))
|
||||
if err != nil {
|
||||
panic("failed to create cache")
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/go-pkgz/lcw"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cache, err := lcw.NewLruCache(lcw.MaxKeys(500), lcw.MaxCacheSize(65536), lcw.MaxValSize(200), lcw.MaxKeySize(32))
|
||||
if err != nil {
|
||||
panic("failed to create cache")
|
||||
}
|
||||
defer cache.Close()
|
||||
|
||||
val, err := cache.Get("key123", func() (interface{}, error) {
|
||||
res, err := getDataFromSomeSource(params) // returns string
|
||||
return res, err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic("failed to get data")
|
||||
}
|
||||
|
||||
s := val.(string) // cached value
|
||||
}
|
||||
defer cache.Close()
|
||||
|
||||
val, err := cache.Get("key123", func() (lcw.Value, error) {
|
||||
res, err := getDataFromSomeSource(params) // returns string
|
||||
return res, err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic("failed to get data")
|
||||
}
|
||||
|
||||
s := val.(string) // cached value
|
||||
```
|
||||
|
||||
### Cache with URI
|
||||
@@ -73,7 +81,6 @@ Cache can be created with URIs:
|
||||
that mutable values can be changed outside of cache. `ExampleLoadingCache_Mutability` illustrates that.
|
||||
- All byte-size limits (MaxCacheSize and MaxValSize) only work for values implementing `lcw.Sizer` interface.
|
||||
- Negative limits (max options) rejected
|
||||
- `lgr.Value` wraps `interface{}` and should be converted back to the concrete type.
|
||||
- The implementation started as a part of [remark42](https://github.com/umputun/remark)
|
||||
and later on moved to [go-pkgz/rest](https://github.com/go-pkgz/rest/tree/master/cache)
|
||||
library and finally generalized to become `lcw`.
|
||||
|
||||
-2
@@ -6,8 +6,6 @@
|
||||
// 3 flavors of cache provided - NoP (do-nothing cache), ExpirableCache (TTL based), and LruCache
|
||||
package lcw
|
||||
|
||||
//go:generate sh -c "mockery -inpkg -name LoadingCache -print > /tmp/cache-mock.tmp && mv /tmp/cache-mock.tmp cache_mock.go"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
+6
@@ -185,6 +185,12 @@ func (c *LoadingCache) ItemCount() int {
|
||||
func (c *LoadingCache) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// don't panic in case service is already closed
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
linters:
|
||||
enable:
|
||||
- megacheck
|
||||
- revive
|
||||
- govet
|
||||
- unconvert
|
||||
- megacheck
|
||||
- gas
|
||||
- gocyclo
|
||||
- dupl
|
||||
- misspell
|
||||
- unparam
|
||||
- unused
|
||||
- typecheck
|
||||
- ineffassign
|
||||
- stylecheck
|
||||
- exportloopref
|
||||
- gocritic
|
||||
- nakedret
|
||||
- gosimple
|
||||
- prealloc
|
||||
fast: false
|
||||
disable-all: true
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- dupl
|
||||
exclude-use-default: false
|
||||
+1
-2
@@ -44,7 +44,7 @@ func New2Q(size int) (*TwoQueueCache, error) {
|
||||
|
||||
// New2QParams creates a new TwoQueueCache using the provided
|
||||
// parameter values.
|
||||
func New2QParams(size int, recentRatio float64, ghostRatio float64) (*TwoQueueCache, error) {
|
||||
func New2QParams(size int, recentRatio, ghostRatio float64) (*TwoQueueCache, error) {
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("invalid size")
|
||||
}
|
||||
@@ -138,7 +138,6 @@ func (c *TwoQueueCache) Add(key, value interface{}) {
|
||||
// Add to the recently seen list
|
||||
c.ensureSpace(false)
|
||||
c.recent.Add(key, value)
|
||||
return
|
||||
}
|
||||
|
||||
// ensureSpace is used to ensure we have space in the cache
|
||||
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
Copyright (c) 2014 HashiCorp, Inc.
|
||||
|
||||
Mozilla Public License, version 2.0
|
||||
|
||||
1. Definitions
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ thread safe LRU cache. It is based on the cache in Groupcache.
|
||||
Documentation
|
||||
=============
|
||||
|
||||
Full docs are available on [Godoc](http://godoc.org/github.com/hashicorp/golang-lru)
|
||||
Full docs are available on [Godoc](https://pkg.go.dev/github.com/hashicorp/golang-lru)
|
||||
|
||||
Example
|
||||
=======
|
||||
|
||||
-1
@@ -173,7 +173,6 @@ func (c *ARCCache) Add(key, value interface{}) {
|
||||
|
||||
// Add to the recently seen list
|
||||
c.t1.Add(key, value)
|
||||
return
|
||||
}
|
||||
|
||||
// replace is used to adaptively evict from either T1 or T2
|
||||
|
||||
+100
-19
@@ -6,10 +6,17 @@ import (
|
||||
"github.com/hashicorp/golang-lru/simplelru"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultEvictedBufferSize defines the default buffer size to store evicted key/val
|
||||
DefaultEvictedBufferSize = 16
|
||||
)
|
||||
|
||||
// Cache is a thread-safe fixed size LRU cache.
|
||||
type Cache struct {
|
||||
lru simplelru.LRUCache
|
||||
lock sync.RWMutex
|
||||
lru *simplelru.LRU
|
||||
evictedKeys, evictedVals []interface{}
|
||||
onEvictedCB func(k, v interface{})
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates an LRU of the given size.
|
||||
@@ -19,30 +26,63 @@ func New(size int) (*Cache, error) {
|
||||
|
||||
// NewWithEvict constructs a fixed size cache with the given eviction
|
||||
// callback.
|
||||
func NewWithEvict(size int, onEvicted func(key interface{}, value interface{})) (*Cache, error) {
|
||||
lru, err := simplelru.NewLRU(size, simplelru.EvictCallback(onEvicted))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func NewWithEvict(size int, onEvicted func(key, value interface{})) (c *Cache, err error) {
|
||||
// create a cache with default settings
|
||||
c = &Cache{
|
||||
onEvictedCB: onEvicted,
|
||||
}
|
||||
c := &Cache{
|
||||
lru: lru,
|
||||
if onEvicted != nil {
|
||||
c.initEvictBuffers()
|
||||
onEvicted = c.onEvicted
|
||||
}
|
||||
return c, nil
|
||||
c.lru, err = simplelru.NewLRU(size, onEvicted)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Cache) initEvictBuffers() {
|
||||
c.evictedKeys = make([]interface{}, 0, DefaultEvictedBufferSize)
|
||||
c.evictedVals = make([]interface{}, 0, DefaultEvictedBufferSize)
|
||||
}
|
||||
|
||||
// onEvicted save evicted key/val and sent in externally registered callback
|
||||
// outside of critical section
|
||||
func (c *Cache) onEvicted(k, v interface{}) {
|
||||
c.evictedKeys = append(c.evictedKeys, k)
|
||||
c.evictedVals = append(c.evictedVals, v)
|
||||
}
|
||||
|
||||
// Purge is used to completely clear the cache.
|
||||
func (c *Cache) Purge() {
|
||||
var ks, vs []interface{}
|
||||
c.lock.Lock()
|
||||
c.lru.Purge()
|
||||
if c.onEvictedCB != nil && len(c.evictedKeys) > 0 {
|
||||
ks, vs = c.evictedKeys, c.evictedVals
|
||||
c.initEvictBuffers()
|
||||
}
|
||||
c.lock.Unlock()
|
||||
// invoke callback outside of critical section
|
||||
if c.onEvictedCB != nil {
|
||||
for i := 0; i < len(ks); i++ {
|
||||
c.onEvictedCB(ks[i], vs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a value to the cache. Returns true if an eviction occurred.
|
||||
func (c *Cache) Add(key, value interface{}) (evicted bool) {
|
||||
var k, v interface{}
|
||||
c.lock.Lock()
|
||||
evicted = c.lru.Add(key, value)
|
||||
if c.onEvictedCB != nil && evicted {
|
||||
k, v = c.evictedKeys[0], c.evictedVals[0]
|
||||
c.evictedKeys, c.evictedVals = c.evictedKeys[:0], c.evictedVals[:0]
|
||||
}
|
||||
c.lock.Unlock()
|
||||
return evicted
|
||||
if c.onEvictedCB != nil && evicted {
|
||||
c.onEvictedCB(k, v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get looks up a key's value from the cache.
|
||||
@@ -75,13 +115,21 @@ func (c *Cache) Peek(key interface{}) (value interface{}, ok bool) {
|
||||
// recent-ness or deleting it for being stale, and if not, adds the value.
|
||||
// Returns whether found and whether an eviction occurred.
|
||||
func (c *Cache) ContainsOrAdd(key, value interface{}) (ok, evicted bool) {
|
||||
var k, v interface{}
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
if c.lru.Contains(key) {
|
||||
c.lock.Unlock()
|
||||
return true, false
|
||||
}
|
||||
evicted = c.lru.Add(key, value)
|
||||
if c.onEvictedCB != nil && evicted {
|
||||
k, v = c.evictedKeys[0], c.evictedVals[0]
|
||||
c.evictedKeys, c.evictedVals = c.evictedKeys[:0], c.evictedVals[:0]
|
||||
}
|
||||
c.lock.Unlock()
|
||||
if c.onEvictedCB != nil && evicted {
|
||||
c.onEvictedCB(k, v)
|
||||
}
|
||||
return false, evicted
|
||||
}
|
||||
|
||||
@@ -89,47 +137,80 @@ func (c *Cache) ContainsOrAdd(key, value interface{}) (ok, evicted bool) {
|
||||
// recent-ness or deleting it for being stale, and if not, adds the value.
|
||||
// Returns whether found and whether an eviction occurred.
|
||||
func (c *Cache) PeekOrAdd(key, value interface{}) (previous interface{}, ok, evicted bool) {
|
||||
var k, v interface{}
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
previous, ok = c.lru.Peek(key)
|
||||
if ok {
|
||||
c.lock.Unlock()
|
||||
return previous, true, false
|
||||
}
|
||||
|
||||
evicted = c.lru.Add(key, value)
|
||||
if c.onEvictedCB != nil && evicted {
|
||||
k, v = c.evictedKeys[0], c.evictedVals[0]
|
||||
c.evictedKeys, c.evictedVals = c.evictedKeys[:0], c.evictedVals[:0]
|
||||
}
|
||||
c.lock.Unlock()
|
||||
if c.onEvictedCB != nil && evicted {
|
||||
c.onEvictedCB(k, v)
|
||||
}
|
||||
return nil, false, evicted
|
||||
}
|
||||
|
||||
// Remove removes the provided key from the cache.
|
||||
func (c *Cache) Remove(key interface{}) (present bool) {
|
||||
var k, v interface{}
|
||||
c.lock.Lock()
|
||||
present = c.lru.Remove(key)
|
||||
if c.onEvictedCB != nil && present {
|
||||
k, v = c.evictedKeys[0], c.evictedVals[0]
|
||||
c.evictedKeys, c.evictedVals = c.evictedKeys[:0], c.evictedVals[:0]
|
||||
}
|
||||
c.lock.Unlock()
|
||||
if c.onEvictedCB != nil && present {
|
||||
c.onEvictedCB(k, v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Resize changes the cache size.
|
||||
func (c *Cache) Resize(size int) (evicted int) {
|
||||
var ks, vs []interface{}
|
||||
c.lock.Lock()
|
||||
evicted = c.lru.Resize(size)
|
||||
if c.onEvictedCB != nil && evicted > 0 {
|
||||
ks, vs = c.evictedKeys, c.evictedVals
|
||||
c.initEvictBuffers()
|
||||
}
|
||||
c.lock.Unlock()
|
||||
if c.onEvictedCB != nil && evicted > 0 {
|
||||
for i := 0; i < len(ks); i++ {
|
||||
c.onEvictedCB(ks[i], vs[i])
|
||||
}
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
// RemoveOldest removes the oldest item from the cache.
|
||||
func (c *Cache) RemoveOldest() (key interface{}, value interface{}, ok bool) {
|
||||
func (c *Cache) RemoveOldest() (key, value interface{}, ok bool) {
|
||||
var k, v interface{}
|
||||
c.lock.Lock()
|
||||
key, value, ok = c.lru.RemoveOldest()
|
||||
if c.onEvictedCB != nil && ok {
|
||||
k, v = c.evictedKeys[0], c.evictedVals[0]
|
||||
c.evictedKeys, c.evictedVals = c.evictedKeys[:0], c.evictedVals[:0]
|
||||
}
|
||||
c.lock.Unlock()
|
||||
if c.onEvictedCB != nil && ok {
|
||||
c.onEvictedCB(k, v)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetOldest returns the oldest entry
|
||||
func (c *Cache) GetOldest() (key interface{}, value interface{}, ok bool) {
|
||||
c.lock.Lock()
|
||||
func (c *Cache) GetOldest() (key, value interface{}, ok bool) {
|
||||
c.lock.RLock()
|
||||
key, value, ok = c.lru.GetOldest()
|
||||
c.lock.Unlock()
|
||||
c.lock.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -25,7 +25,7 @@ type entry struct {
|
||||
// NewLRU constructs an LRU of the given size
|
||||
func NewLRU(size int, onEvict EvictCallback) (*LRU, error) {
|
||||
if size <= 0 {
|
||||
return nil, errors.New("Must provide a positive size")
|
||||
return nil, errors.New("must provide a positive size")
|
||||
}
|
||||
c := &LRU{
|
||||
size: size,
|
||||
@@ -109,7 +109,7 @@ func (c *LRU) Remove(key interface{}) (present bool) {
|
||||
}
|
||||
|
||||
// RemoveOldest removes the oldest item from the cache.
|
||||
func (c *LRU) RemoveOldest() (key interface{}, value interface{}, ok bool) {
|
||||
func (c *LRU) RemoveOldest() (key, value interface{}, ok bool) {
|
||||
ent := c.evictList.Back()
|
||||
if ent != nil {
|
||||
c.removeElement(ent)
|
||||
@@ -120,7 +120,7 @@ func (c *LRU) RemoveOldest() (key interface{}, value interface{}, ok bool) {
|
||||
}
|
||||
|
||||
// GetOldest returns the oldest entry
|
||||
func (c *LRU) GetOldest() (key interface{}, value interface{}, ok bool) {
|
||||
func (c *LRU) GetOldest() (key, value interface{}, ok bool) {
|
||||
ent := c.evictList.Back()
|
||||
if ent != nil {
|
||||
kv := ent.Value.(*entry)
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
// Package simplelru provides simple LRU implementation based on build-in container/list.
|
||||
package simplelru
|
||||
|
||||
// LRUCache is the interface for simple LRU cache.
|
||||
@@ -34,6 +35,6 @@ type LRUCache interface {
|
||||
// Clears all cache entries.
|
||||
Purge()
|
||||
|
||||
// Resizes cache, returning number evicted
|
||||
Resize(int) int
|
||||
// Resizes cache, returning number evicted
|
||||
Resize(int) int
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package lru
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math"
|
||||
"math/big"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func getRand(tb testing.TB) int64 {
|
||||
out, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return out.Int64()
|
||||
}
|
||||
Vendored
+6
-6
@@ -65,7 +65,7 @@ github.com/go-chi/render
|
||||
github.com/go-oauth2/oauth2/v4
|
||||
github.com/go-oauth2/oauth2/v4/errors
|
||||
github.com/go-oauth2/oauth2/v4/server
|
||||
# github.com/go-pkgz/auth v1.20.0
|
||||
# github.com/go-pkgz/auth v1.20.1-0.20230103203948-168bd5a101b7
|
||||
## explicit; go 1.17
|
||||
github.com/go-pkgz/auth
|
||||
github.com/go-pkgz/auth/avatar
|
||||
@@ -74,8 +74,8 @@ github.com/go-pkgz/auth/middleware
|
||||
github.com/go-pkgz/auth/provider
|
||||
github.com/go-pkgz/auth/provider/sender
|
||||
github.com/go-pkgz/auth/token
|
||||
# github.com/go-pkgz/email v0.4.0
|
||||
## explicit; go 1.17
|
||||
# github.com/go-pkgz/email v0.4.1
|
||||
## explicit; go 1.19
|
||||
github.com/go-pkgz/email
|
||||
# github.com/go-pkgz/expirable-cache v0.1.0
|
||||
## explicit; go 1.14
|
||||
@@ -83,7 +83,7 @@ github.com/go-pkgz/expirable-cache
|
||||
# github.com/go-pkgz/jrpc v0.3.0
|
||||
## explicit; go 1.16
|
||||
github.com/go-pkgz/jrpc
|
||||
# github.com/go-pkgz/lcw v1.0.1
|
||||
# github.com/go-pkgz/lcw v1.0.3-0.20221226231215-a66ea7c4aff7
|
||||
## explicit; go 1.15
|
||||
github.com/go-pkgz/lcw
|
||||
github.com/go-pkgz/lcw/eventbus
|
||||
@@ -143,7 +143,7 @@ github.com/hashicorp/errwrap
|
||||
# github.com/hashicorp/go-multierror v1.1.1
|
||||
## explicit; go 1.13
|
||||
github.com/hashicorp/go-multierror
|
||||
# github.com/hashicorp/golang-lru v0.5.4
|
||||
# github.com/hashicorp/golang-lru v0.6.0
|
||||
## explicit; go 1.12
|
||||
github.com/hashicorp/golang-lru
|
||||
github.com/hashicorp/golang-lru/simplelru
|
||||
@@ -196,7 +196,7 @@ github.com/slack-go/slack/internal/backoff
|
||||
github.com/slack-go/slack/internal/errorsx
|
||||
github.com/slack-go/slack/internal/timex
|
||||
github.com/slack-go/slack/slackutilsx
|
||||
# github.com/stretchr/testify v1.8.0
|
||||
# github.com/stretchr/testify v1.8.1
|
||||
## explicit; go 1.13
|
||||
github.com/stretchr/testify/assert
|
||||
github.com/stretchr/testify/require
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {
|
||||
// do nothing
|
||||
}
|
||||
unobserve() {
|
||||
// do nothing
|
||||
}
|
||||
disconnect() {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.2 4.15c.78 0 1.76-.55 2.34-1.28.52-.66.9-1.59.9-2.52 0-.12 0-.25-.03-.35-.86.03-1.9.6-2.52 1.37-.5.59-.94 1.5-.94 2.44 0 .14.02.28.03.32l.23.02ZM7.48 18c1.06 0 1.53-.74 2.86-.74 1.34 0 1.64.72 2.82.72 1.16 0 1.94-1.12 2.67-2.22.82-1.26 1.16-2.5 1.18-2.55a3.93 3.93 0 0 1-2.3-3.64c0-2.32 1.75-3.36 1.85-3.44a3.98 3.98 0 0 0-3.4-1.78c-1.3 0-2.36.82-3.03.82-.72 0-1.67-.78-2.8-.78C5.18 4.4 3 6.25 3 9.75c0 2.17.8 4.47 1.8 5.96C5.66 16.97 6.4 18 7.47 18Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 559 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.2 4.15c.78 0 1.76-.55 2.34-1.28.52-.66.9-1.59.9-2.52 0-.12 0-.25-.03-.35-.86.03-1.9.6-2.52 1.37-.5.59-.94 1.5-.94 2.44 0 .14.02.28.03.32l.23.02ZM7.48 18c1.06 0 1.53-.74 2.86-.74 1.34 0 1.64.72 2.82.72 1.16 0 1.94-1.12 2.67-2.22.82-1.26 1.16-2.5 1.18-2.55a3.93 3.93 0 0 1-2.3-3.64c0-2.32 1.75-3.36 1.85-3.44a3.98 3.98 0 0 0-3.4-1.78c-1.3 0-2.36.82-3.03.82-.72 0-1.67-.78-2.8-.78C5.18 4.4 3 6.25 3 9.75c0 2.17.8 4.47 1.8 5.96C5.66 16.97 6.4 18 7.47 18Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 559 B |
@@ -9,8 +9,6 @@ export const getConfig = (): Promise<Config> => apiFetcher.get('/config');
|
||||
|
||||
export const getPostComments = (sort: Sorting) => apiFetcher.get<Tree>('/find', { url, sort, format: 'tree' });
|
||||
|
||||
export const getComment = (id: Comment['id']): Promise<Comment> => apiFetcher.get(`/id/${id}`, { url });
|
||||
|
||||
export const getUserComments = (
|
||||
userId: User['id'],
|
||||
config: { limit: number; skip?: number } = { limit: 10, skip: 0 }
|
||||
|
||||
@@ -38,7 +38,7 @@ export const IS_STORAGE_AVAILABLE: boolean = (() => {
|
||||
})();
|
||||
|
||||
/**
|
||||
* Defines whether iframe loaded in cross origin environment
|
||||
* Defines whether iframe loaded in cross-origin environment
|
||||
* Useful for checking if some privacy restriction may be applied
|
||||
*/
|
||||
export const IS_THIRD_PARTY: boolean = (() => {
|
||||
|
||||
@@ -43,7 +43,3 @@ export function getCookie(name: string) {
|
||||
|
||||
return matches ? decodeURIComponent(matches[1]) : undefined;
|
||||
}
|
||||
|
||||
export function deleteCookie(name: string) {
|
||||
setCookie(name, '', { expires: -1 });
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type Methods = {
|
||||
delete: BodylessMethod;
|
||||
};
|
||||
|
||||
/** JWT token received from server and will be send by each request, if it present */
|
||||
/** JWT token received from server and will be sent by each request, if it is present */
|
||||
let activeJwtToken: string | undefined;
|
||||
|
||||
const createFetcher = (baseUrl: string = ''): Methods => {
|
||||
@@ -69,9 +69,7 @@ const createFetcher = (baseUrl: string = ''): Methods => {
|
||||
// TODO: it should be clarified when frontend gets this header and what could be in it to simplify this logic and cover by tests
|
||||
const date = (res.headers.has('date') && res.headers.get('date')) || '';
|
||||
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
|
||||
const timeDiff = (new Date().getTime() - timestamp) / 1000;
|
||||
|
||||
StaticStore.serverClientTimeDiff = timeDiff;
|
||||
StaticStore.serverClientTimeDiff = (new Date().getTime() - timestamp) / 1000;
|
||||
|
||||
// backend could update jwt in any time. so, we should handle it
|
||||
if (res.headers.has(JWT_HEADER)) {
|
||||
|
||||
@@ -26,3 +26,4 @@ export const pageTitle = rawParams.page_title;
|
||||
export const url = rawParams.url;
|
||||
export const token = rawParams.token;
|
||||
export const locale = rawParams.locale || 'en';
|
||||
export const noFooter = rawParams.no_footer === 'true';
|
||||
|
||||
@@ -72,12 +72,6 @@ export interface Comment {
|
||||
*/
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface CommentsResponse {
|
||||
comments: Comment[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
comment: Comment;
|
||||
replies?: Node[];
|
||||
@@ -97,6 +91,7 @@ export interface Tree {
|
||||
}
|
||||
|
||||
export type OAuthProvider =
|
||||
| 'apple'
|
||||
| 'facebook'
|
||||
| 'twitter'
|
||||
| 'google'
|
||||
|
||||
@@ -3,16 +3,9 @@ import { useIntl } from 'react-intl';
|
||||
|
||||
import { errorMessages, RequestError } from 'utils/errorUtils';
|
||||
import { isObject } from 'utils/is-object';
|
||||
import { parseMessage, postMessageToParent } from 'utils/post-message';
|
||||
import { parseMessage, updateIframeHeight } from 'utils/post-message';
|
||||
import { messages } from './auth.messsages';
|
||||
|
||||
function handleChangeIframeSize(element: HTMLElement) {
|
||||
const { top } = element.getBoundingClientRect();
|
||||
const height = Math.max(window.scrollY + Math.abs(top) + element.scrollHeight + 20, document.body.offsetHeight);
|
||||
|
||||
postMessageToParent({ height });
|
||||
}
|
||||
|
||||
export function useDropdown(disableClosing?: boolean) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const clickInsideRef = useRef<boolean>(false);
|
||||
@@ -74,13 +67,13 @@ export function useDropdown(disableClosing?: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleChangeIframeSize(dropdownElement);
|
||||
updateIframeHeight(dropdownElement);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
handleChangeIframeSize(dropdownElement);
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateIframeHeight(dropdownElement);
|
||||
});
|
||||
|
||||
observer.observe(dropdownElement, { attributes: true, childList: true, subtree: true });
|
||||
observer.observe(dropdownElement);
|
||||
|
||||
return () => {
|
||||
document.body.style.removeProperty('min-height');
|
||||
@@ -123,7 +116,7 @@ export function useErrorMessage(): [string | null, (e: unknown) => void] {
|
||||
}
|
||||
|
||||
const errorReason =
|
||||
err instanceof RequestError || (isObject(err) && typeof (err as Record<string, string>).error === 'string')
|
||||
err instanceof RequestError || isObject(err)
|
||||
? (err as Record<'error', string>).error
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
|
||||
@@ -27,7 +27,7 @@ export const messages = defineMessages<string>({
|
||||
},
|
||||
token: {
|
||||
id: 'token',
|
||||
defaultMessage: 'Token',
|
||||
defaultMessage: 'Copy and paste the token from the email',
|
||||
},
|
||||
expiredToken: {
|
||||
id: 'token.expired',
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('<Auth/>', () => {
|
||||
[['facebook', 'google', 'microsoft']],
|
||||
[['facebook', 'google', 'microsoft', 'yandex']],
|
||||
[['facebook', 'google', 'microsoft', 'yandex', 'twitter']],
|
||||
[['facebook', 'google', 'microsoft', 'yandex', 'twitter', 'apple']],
|
||||
] as [OAuthProvider[]][])('should renders with %j providers', async (providers) => {
|
||||
StaticStore.config.auth_providers = providers;
|
||||
|
||||
@@ -158,9 +159,9 @@ describe('<Auth/>', () => {
|
||||
|
||||
expect(screen.getByText('Back')).toHaveClass('auth-back-button');
|
||||
expect(screen.getByTitle('Close sign-in dropdown')).toHaveClass('auth-close-button');
|
||||
expect(screen.getByPlaceholderText('Token')).toHaveClass('auth-token-textarea');
|
||||
expect(screen.getByPlaceholderText('Copy and paste the token from the email')).toHaveClass('auth-token-textarea');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Token'), {
|
||||
fireEvent.change(screen.getByPlaceholderText('Copy and paste the token from the email'), {
|
||||
target: { value: 'token' },
|
||||
});
|
||||
|
||||
@@ -187,9 +188,9 @@ describe('<Auth/>', () => {
|
||||
|
||||
expect(getByText('Back')).toHaveClass('auth-back-button');
|
||||
expect(getByTitle('Close sign-in dropdown')).toHaveClass('auth-close-button');
|
||||
expect(getByPlaceholderText('Token')).toHaveClass('auth-token-textarea');
|
||||
expect(getByPlaceholderText('Copy and paste the token from the email')).toHaveClass('auth-token-textarea');
|
||||
|
||||
fireEvent.change(getByPlaceholderText('Token'), { target: { value: 'token' } });
|
||||
fireEvent.change(getByPlaceholderText('Copy and paste the token from the email'), { target: { value: 'token' } });
|
||||
fireEvent.click(getByText('Submit'));
|
||||
await waitFor(() => expect(utils.getTokenInvalidReason).toBeCalled());
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export const OAUTH_DATA = {
|
||||
apple: {
|
||||
name: 'Apple',
|
||||
icons: {
|
||||
light: require('assets/social/apple-light.svg').default as string,
|
||||
dark: require('assets/social/apple-dark.svg').default as string,
|
||||
},
|
||||
},
|
||||
facebook: require('assets/social/facebook.svg').default as string,
|
||||
twitter: require('assets/social/twitter.svg').default as string,
|
||||
patreon: require('assets/social/patreon.svg').default as string,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
line-height: 1.4;
|
||||
border: 0;
|
||||
resize: none;
|
||||
overflow: hidden; /* prevent scrollbar from appearing */
|
||||
backface-visibility: hidden; /* let's try to fix blinking in Safari */
|
||||
transform: translateZ(0); /* let's try to fix blinking in Safari, again */
|
||||
|
||||
|
||||
+1
-2
@@ -26,7 +26,6 @@ const emailRegexp = /[^@]+@[^.]+\..+/;
|
||||
enum Step {
|
||||
Email,
|
||||
Token,
|
||||
Final,
|
||||
Close,
|
||||
Subscribed,
|
||||
Unsubscribed,
|
||||
@@ -35,7 +34,7 @@ enum Step {
|
||||
const messages = defineMessages({
|
||||
token: {
|
||||
id: 'token',
|
||||
defaultMessage: 'Token',
|
||||
defaultMessage: 'Copy and paste the token from the email',
|
||||
},
|
||||
expiredToken: {
|
||||
id: 'token.expired',
|
||||
|
||||
@@ -112,7 +112,7 @@ export class CommentForm extends Component<Props, State> {
|
||||
|
||||
onInput = (e: Event) => {
|
||||
const { value } = e.target as HTMLInputElement;
|
||||
const text = value.substr(0, StaticStore.config.max_comment_size);
|
||||
const text = value.substring(0, StaticStore.config.max_comment_size);
|
||||
|
||||
updatePersistedComments(this.props.id, value);
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('<CommentVote />', () => {
|
||||
['downvote', -1, 'Vote down', 'Vote up', 'downVoteButtonActive'],
|
||||
])(
|
||||
'should go throught voting process and communicate with store when %s button is clicked',
|
||||
async (_, increment, activeButtonText, secondButtonText, activeButtonClass) => {
|
||||
async (_, increment, activeButtonText, secondButtonText) => {
|
||||
const putCommentVoteSpy = jest
|
||||
.spyOn(api, 'putCommentVote')
|
||||
.mockImplementationOnce(({ vote }) => Promise.resolve({ id: '1', score: 10 + vote }));
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function CommentVotes({ id, votes, vote, disabled, controversy = 0 }: Props) {
|
||||
export function CommentVotes({ id, votes, vote, disabled }: Props) {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [loadingState, setLoadingState] = useState<{ vote: number; votes: number } | null>(null);
|
||||
|
||||
@@ -62,7 +62,6 @@ export interface State {
|
||||
}
|
||||
|
||||
export class Comment extends Component<CommentProps, State> {
|
||||
votingPromise: Promise<unknown> = Promise.resolve();
|
||||
/** comment text node. Used in comment text copying */
|
||||
textNode = createRef<HTMLDivElement>();
|
||||
|
||||
@@ -88,7 +87,7 @@ export class Comment extends Component<CommentProps, State> {
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines whether comment made by logged in user
|
||||
* Defines whether comment made by logged-in user
|
||||
*/
|
||||
isCurrentUser = (): boolean => {
|
||||
return !this.isGuest() && this.props.data.user.id === this.props.user?.id;
|
||||
@@ -175,16 +174,6 @@ export class Comment extends Component<CommentProps, State> {
|
||||
}
|
||||
};
|
||||
|
||||
onBlockUserClick = (evt: Event) => {
|
||||
const target = evt.currentTarget;
|
||||
|
||||
if (target instanceof HTMLOptionElement) {
|
||||
// we have to debounce the blockUser function calls otherwise it will be
|
||||
// called 2 times (by change event and by blur event)
|
||||
this.blockUser(target.value as BlockTTL);
|
||||
}
|
||||
};
|
||||
|
||||
blockUser = debounce((ttl: BlockTTL): void => {
|
||||
const { user } = this.props.data;
|
||||
const blockingDurations = getBlockingDurations(this.props.intl);
|
||||
@@ -427,7 +416,7 @@ export class Comment extends Component<CommentProps, State> {
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{!isAdmin && !!o.user.verified && props.view !== 'user' && (
|
||||
{!isAdmin && o.user.verified && props.view !== 'user' && (
|
||||
<VerificationIcon className={styles.verificationIcon} title={intl.formatMessage(messages.verifiedUser)} />
|
||||
)}
|
||||
{o.user.paid_sub && (
|
||||
@@ -560,7 +549,7 @@ function getTextSnippet(html: string) {
|
||||
tmp.innerHTML = html.replace('</p><p>', ' ');
|
||||
|
||||
const result = tmp.innerText || '';
|
||||
const snippet = result.substr(0, LENGTH);
|
||||
const snippet = result.substring(0, LENGTH);
|
||||
|
||||
return snippet.length === LENGTH && result.length !== LENGTH ? `${snippet}...` : snippet;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function Profile() {
|
||||
const [error, setError] = useState(false);
|
||||
const [comments, setComments] = useState<CommentType[] | null>(null);
|
||||
const [commentsAmount, setCommentsAmount] = useState(0);
|
||||
// store skip count in ref because it don't affect the view
|
||||
// store skip count in ref because it doesn't affect the view
|
||||
const commentsSkipCountsRef = useRef(0);
|
||||
const [isSigningOut, setSigningOut] = useState(false);
|
||||
|
||||
@@ -179,7 +179,7 @@ export function Profile() {
|
||||
return (
|
||||
<div className={clsx('profile', styles.root)} ref={rootRef}>
|
||||
{/* disable jsx-a11y/no-static-element-interactions and jsx-a11y/click-events-have-key-events */}
|
||||
{/* that's fine because inside of the element we have button that will throw all events and provide all of the interactions */}
|
||||
{/* that's fine because inside the element we have button that will throw all events and provide all the interactions */}
|
||||
{/* eslint-disable-next-line */}
|
||||
<div className={clsx('profile-close-button-wrapper', styles.closeButtonWrapper)} onClick={handleClickClose}>
|
||||
<IconButton title={intl.formatMessage({ id: 'profile.close', defaultMessage: 'Close profile' })}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, Component, Fragment } from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useSelector } from 'react-redux';
|
||||
import b from 'bem-react-helper';
|
||||
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
@@ -8,10 +8,9 @@ import clsx from 'clsx';
|
||||
import 'styles/global.css';
|
||||
import type { StoreState } from 'store';
|
||||
import { COMMENT_NODE_CLASSNAME_PREFIX, MAX_SHOWN_ROOT_COMMENTS, THEMES, IS_MOBILE } from 'common/constants';
|
||||
import { maxShownComments, url } from 'common/settings';
|
||||
import { maxShownComments, noFooter, url } from 'common/settings';
|
||||
|
||||
import {
|
||||
setUser,
|
||||
fetchUser,
|
||||
blockUser,
|
||||
unblockUser,
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
unhideUser,
|
||||
signout,
|
||||
} from 'store/user/actions';
|
||||
import { fetchComments, addComment, updateComment, unsetCommentMode } from 'store/comments/actions';
|
||||
import { fetchComments, addComment, updateComment } from 'store/comments/actions';
|
||||
import { setCommentsReadOnlyState } from 'store/post-info/actions';
|
||||
import { setTheme } from 'store/theme/actions';
|
||||
|
||||
@@ -36,7 +35,7 @@ import { ConnectedComment as Comment } from 'components/comment/connected-commen
|
||||
import { uploadImage, getPreview } from 'common/api';
|
||||
import { isUserAnonymous } from 'utils/isUserAnonymous';
|
||||
import { bindActions } from 'utils/actionBinder';
|
||||
import { postMessageToParent, parseMessage } from 'utils/post-message';
|
||||
import { postMessageToParent, parseMessage, updateIframeHeight } from 'utils/post-message';
|
||||
import { useActions } from 'hooks/useAction';
|
||||
import { setCollapse } from 'store/thread/actions';
|
||||
|
||||
@@ -66,7 +65,6 @@ const mapStateToProps = (state: StoreState) => ({
|
||||
|
||||
const boundActions = bindActions({
|
||||
fetchComments,
|
||||
setUser,
|
||||
fetchUser,
|
||||
fetchBlockedUsers,
|
||||
setTheme,
|
||||
@@ -78,7 +76,6 @@ const boundActions = bindActions({
|
||||
addComment,
|
||||
updateComment,
|
||||
setCollapse,
|
||||
unsetCommentMode,
|
||||
signout,
|
||||
});
|
||||
|
||||
@@ -287,10 +284,6 @@ export class Root extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
function updateIframeHeight() {
|
||||
postMessageToParent({ height: document.body.offsetHeight });
|
||||
}
|
||||
|
||||
interface CommentsProps {
|
||||
isLoading: boolean;
|
||||
topComments: string[];
|
||||
@@ -298,34 +291,12 @@ interface CommentsProps {
|
||||
showMore(): void;
|
||||
}
|
||||
function Comments({ isLoading, topComments, commentsShown, showMore }: CommentsProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: throttle updates
|
||||
const observer = new MutationObserver(() => {
|
||||
updateIframeHeight();
|
||||
|
||||
// a hacky way to force iframe height update when new image is rendered and loaded
|
||||
rootRef.current?.querySelectorAll('img').forEach((img) => {
|
||||
img.addEventListener('load', updateIframeHeight);
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(rootRef.current, { attributes: true, childList: true, subtree: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const renderComments =
|
||||
IS_MOBILE && commentsShown < topComments.length ? topComments.slice(0, commentsShown) : topComments;
|
||||
const isShowMoreButtonVisible = IS_MOBILE && commentsShown < topComments.length;
|
||||
|
||||
return (
|
||||
<div className="root__threads" role="list" ref={rootRef}>
|
||||
<div className="root__threads" role="list">
|
||||
{isLoading ? (
|
||||
<Preloader className="root__preloader" />
|
||||
) : (
|
||||
@@ -357,16 +328,26 @@ export function ConnectedRoot() {
|
||||
const props = useSelector(mapStateToProps);
|
||||
const actions = useActions(boundActions);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => updateIframeHeight());
|
||||
|
||||
updateIframeHeight();
|
||||
observer.observe(document.body);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={clsx(b('root', {}, { theme: props.theme }), props.theme)}>
|
||||
<Root {...props} {...actions} intl={intl} />
|
||||
<p className="root__copyright" role="contentinfo">
|
||||
<FormattedMessage
|
||||
id="root.powered-by"
|
||||
defaultMessage="Powered by <a>Remark42</a>"
|
||||
values={{ a: CopyrightLink }}
|
||||
/>
|
||||
</p>
|
||||
{!noFooter && (
|
||||
<p className="root__copyright" role="contentinfo">
|
||||
<FormattedMessage
|
||||
id="root.powered-by"
|
||||
defaultMessage="Powered by <a>Remark42</a>"
|
||||
values={{ a: CopyrightLink }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,8 +91,7 @@ class SettingsComponent extends Component<Props, State> {
|
||||
};
|
||||
|
||||
__isUserHidden = (user: User): boolean => {
|
||||
if (this.state.unhiddenUsers.indexOf(user.id) === -1) return true;
|
||||
return false;
|
||||
return !this.state.unhiddenUsers.includes(user.id);
|
||||
};
|
||||
|
||||
render({ user, theme }: Props, { blockedUsers, unblockedUsers, unhiddenUsers }: State) {
|
||||
|
||||
@@ -8,7 +8,7 @@ function autoResize(textarea: HTMLTextAreaElement) {
|
||||
}
|
||||
|
||||
type Props = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'onInput'> & {
|
||||
onInput?: (evt: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
|
||||
onInput?(evt: JSX.TargetedEvent<HTMLTextAreaElement, Event>): void;
|
||||
};
|
||||
|
||||
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, Props>(({ onInput, value, ...props }, externalRef) => {
|
||||
|
||||
@@ -4,7 +4,5 @@ import { StoreState } from 'store';
|
||||
import { Theme } from 'common/types';
|
||||
|
||||
export function useTheme() {
|
||||
const theme = useSelector<StoreState, Theme>(({ theme }) => theme);
|
||||
|
||||
return theme;
|
||||
return useSelector<StoreState, Theme>(({ theme }) => theme);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "الموقع",
|
||||
"subscribeByRSS.thread": "سلسلة ردود",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "رمز",
|
||||
"token": "انسخ والصق الرمز المميز من البريد الإلكتروني",
|
||||
"token.expired": "الرمز انتهت صلاحيته",
|
||||
"token.invalid": "الرمز غير صالح",
|
||||
"toolbar.attach-image": "أرفق صورةً، أو اسحبها وأسقطها، أو الصقها من الحافظة",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "Сайт",
|
||||
"subscribeByRSS.thread": "Гутарка",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Токен",
|
||||
"token": "Скапіруйце і ўстаўце токен з электроннага ліста",
|
||||
"token.expired": "Час дзеяння токена сышоў",
|
||||
"token.invalid": "Токен несапраўдны",
|
||||
"toolbar.attach-image": "Прымацаваць выяву, перацягніце або ўстаўце выяву з буфера абмену",
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
"subscribeByRSS.site": "Сайт",
|
||||
"subscribeByRSS.thread": "Нишка",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Жетон",
|
||||
"token.expired": "Изтекъл жетон",
|
||||
"token.invalid": "Token is invalid",
|
||||
"token": "Копирайте и поставете токена от имейла",
|
||||
"token.expired": "Изтекъл токен",
|
||||
"token.invalid": "Токенът е невалиден",
|
||||
"toolbar.attach-image": "Добави картина, премести или копирай от клипборда",
|
||||
"toolbar.bold": "Добави удебелен текст {shortcut}",
|
||||
"toolbar.code": "Вмъкни код",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Tópico",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Token",
|
||||
"token": "Copiar e colar a ficha do e-mail",
|
||||
"token.expired": "O token expirou",
|
||||
"token.invalid": "Token inválido",
|
||||
"toolbar.attach-image": "Anexe a imagem, arraste e solte ou cole da área de transferência",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"auth.back": "Zpět",
|
||||
"auth.email-address": "Emailová adresa",
|
||||
"auth.loading": "Načítání...",
|
||||
"auth.oauth-button": "Přihlásit se pomocí {provider}",
|
||||
"auth.oauth-source": "Použít sociální sítě",
|
||||
"auth.open-profile": "Otevřít můj profil",
|
||||
"auth.or": "nebo",
|
||||
"auth.signin": "Přihlásit se",
|
||||
"auth.signout": "Odhlásit se",
|
||||
"auth.submit": "Odesalt",
|
||||
"auth.symbols-restriction": "Uživatelské jméno musí obsahovat pouze písmena, čísla, podtržítka nebo mezery",
|
||||
"auth.telegram-check": "Zkontrolovat",
|
||||
"auth.telegram-link": "pomocí odkazu",
|
||||
"auth.telegram-message-1": "Otevřít Telegram",
|
||||
"auth.telegram-message-2": "a zde klepněte na tlačítko “Start”",
|
||||
"auth.telegram-message-3": "Poté klepněte na tlačítko “Zkontrolovat”",
|
||||
"auth.telegram-optional-qr": "nebo naskenujte QR kód",
|
||||
"auth.telegram-qr": "Telegram QR kód",
|
||||
"auth.user-not-found": "Nebyl nalezen žádný uživatel",
|
||||
"auth.username": "Uživatelské jméno",
|
||||
"authPanel.disable-comments": "Zakázat komentáře",
|
||||
"authPanel.disabled-cookies": "Zakázat blokování souborů cookie třetích stran pro přihlášení nebo otevření komentářů",
|
||||
"authPanel.enable-comments": "Povolit komentáře",
|
||||
"authPanel.enable-cookies": "Povolit soubory cookie pro přihlášení a komentáře",
|
||||
"authPanel.hide-settings": "Skrýt nastavení",
|
||||
"authPanel.new-page": "nová stránka",
|
||||
"authPanel.read-only": "Pouze pro čtení",
|
||||
"authPanel.show-settings": "Zobrazit nastavení",
|
||||
"blockingDuration.day": "Na den",
|
||||
"blockingDuration.month": "Na měsíc",
|
||||
"blockingDuration.permanently": "Trvale",
|
||||
"blockingDuration.week": "Na týden",
|
||||
"comment.block": "Blokovat",
|
||||
"comment.block-user": "Chcete zablokovat uživatele {userName} na {duration}?",
|
||||
"comment.blocked-user": "Uživatel zablokován",
|
||||
"comment.blocking-period": "Doba blokování",
|
||||
"comment.cancel": "Zrušit",
|
||||
"comment.copied": "Zkopírováno!",
|
||||
"comment.copy": "Kopírovat",
|
||||
"comment.delete": "Smazat",
|
||||
"comment.delete-message": "Opravdu chccete smazat tento komentář?",
|
||||
"comment.deleted-comment": "Komentář byl smazán",
|
||||
"comment.deleted-user": "Odstraněný uživatel",
|
||||
"comment.edit": "Upravit",
|
||||
"comment.edit-countdown": "Editace bude zakázána",
|
||||
"comment.expired-time": "Čas pro editaci komentáře vypršel",
|
||||
"comment.go-to-parent": "Přejít na nadřazený komentář",
|
||||
"comment.hide": "Skrýt",
|
||||
"comment.hide-user-comment": "Opravdu chcete skrýt komentář uživatele {userName}?",
|
||||
"comment.paid-patreon": "Placený odběratel na Patreonu",
|
||||
"comment.pin": "Připnout",
|
||||
"comment.pin-comment": "Opravdu chcete připnout tento komentář?",
|
||||
"comment.reply": "Odpovědět",
|
||||
"comment.time": "{day} v {time}",
|
||||
"comment.toggle-verification": "Přepnout ověření",
|
||||
"comment.unblock": "Odblokovat",
|
||||
"comment.unblock-user": "Opravdu chcete odblokovat uživatele?",
|
||||
"comment.unpin": "Odepnout",
|
||||
"comment.unpin-comment": "Opravdu chete odepnout komentář?",
|
||||
"comment.unverified-user": "Neověřený uživatel",
|
||||
"comment.unverify-user": "Opravdu chcete odebrat ověření uživateli {userName}?",
|
||||
"comment.verified-user": "Ověřený uživatel",
|
||||
"comment.verify-user": "Opravdu chcete uživatele {userName} ověřit?",
|
||||
"commentForm.anonymous-uploading-disabled": "Nahrávání obrázků je pro anonymní uživatele zakázáno. Abyste mohli přikládat obrázky, nepřihlašujte se jako anonymní uživatelé.",
|
||||
"commentForm.exceeded-size": "{fileName} překračuje limit velikosti souborů (max. {maxImageSize})",
|
||||
"commentForm.input-placeholder": "Zde napište Váš komentář",
|
||||
"commentForm.new-comment": "Nový komentář",
|
||||
"commentForm.notice-about-styling": "Pole podporuje <a>Markdown</a> syntax",
|
||||
"commentForm.preview": "Náhled",
|
||||
"commentForm.reply": "Odpovědět",
|
||||
"commentForm.save": "Uložit",
|
||||
"commentForm.send": "Odeslat",
|
||||
"commentForm.subscribe-by": "Přihlásit se k odběru",
|
||||
"commentForm.subscribe-or": "nebo",
|
||||
"commentForm.unauthorized-uploading-disabled": "Nahrávání obrázků je pro neautorizované uživatele zakázáno. Před nahráváním byste se měli přihlásit.",
|
||||
"commentForm.unexpected-error": "Něco se pokazilo. Zkuste to prosím později.",
|
||||
"commentForm.upload-file-fail": "{fileName} se nepodařilo nahrát z důvodu \"{errorMessage}\"",
|
||||
"commentForm.uploading": "Nahrávání...",
|
||||
"commentForm.uploading-file": "nahrávání {fileName}...",
|
||||
"commentsSort.best": "Nejlepší",
|
||||
"commentsSort.least-controversial": "Nejméně kontroverzní",
|
||||
"commentsSort.least-recently-updated": "Nejméně aktualizované",
|
||||
"commentsSort.most-controversial": "Nejvíce kontroverzní",
|
||||
"commentsSort.newest": "Nejnovější",
|
||||
"commentsSort.oldest": "Nejstarší",
|
||||
"commentsSort.recently-updated": "Nedávno aktualizované",
|
||||
"commentsSort.worst": "Nejhorší",
|
||||
"empty-state": "Zatím zde nejsou žádné komentáře.",
|
||||
"errors.0": "Něco se pokazilo. Zkuste provést akci pozdějin",
|
||||
"errors.1": "Komentář nelze nalézt. Obnovte prosím stránku a zkuste to znovu.",
|
||||
"errors.10": "Vypršel čas pro editaci komentáře",
|
||||
"errors.11": "Komentář již obsahuje odpověď, editace není možná.",
|
||||
"errors.12": "Nelze hlasovat pro tento komentář. Zkuste to prosím později.",
|
||||
"errors.13": "Nemůžete hlasovat pro svůj vlastní komentář",
|
||||
"errors.14": "Pro tento komentář jste již hlasoval",
|
||||
"errors.15": "Tento komentář již obsahuje příliš mnoho hlasů",
|
||||
"errors.16": "U tohoto komentáře bylo dosaženo minimálního skóre.",
|
||||
"errors.17": "Akce zamítnuta. Zkuste to prosím znovu o něco později.",
|
||||
"errors.18": "Requested file cannot be found.",
|
||||
"errors.19": "Požadovaný soubor nelze nalézt.",
|
||||
"errors.2": "Nepodařilo se zrušit příchozí požadavek.",
|
||||
"errors.20": "Odeslaný obrázek nebyl nalezen. Zkuste jej nahrát znovu.",
|
||||
"errors.3": "K této operaci nemáte oprávnění.",
|
||||
"errors.4": "Komentář obsahuje neplatná data",
|
||||
"errors.5": "Komentář nenalezen. Obnovte stránku a zkuste to znovu",
|
||||
"errors.6": "Stránku nelze nalézt. Obnovte prosím stránku a zkuste to znovu.",
|
||||
"errors.7": "Uživatel byl zablokován..",
|
||||
"errors.8": "Nelze přidávat komentáře, protože jsou pouze pro čtení",
|
||||
"errors.9": "Editace komentáře se nezdařila. Zkuste to prosím později.",
|
||||
"errors.failed-fetch": "Nepodařilo se načíst. Zkontrolujte prosím své internetové připojení nebo to zkuste později.",
|
||||
"errors.forbidden": "Zakázáno.",
|
||||
"errors.not-authorized": "Nejste přihlášen.",
|
||||
"errors.to-many-request": "Dosáhli jste maximálního limitu požadavků.",
|
||||
"errors.unexpected-error": "Něco se pokazilo.",
|
||||
"profile.close": "Zavřít profil",
|
||||
"profile.request-to-delete-data": "Žádost o odstranění mých údajů",
|
||||
"retry": "Zkusit znovu",
|
||||
"root.pinned-comments": "Připnuté komentáře",
|
||||
"root.powered-by": "Běží na <a>Remark42</a>",
|
||||
"root.show-more": "Zobrazit více",
|
||||
"settings.block": "Blokovat",
|
||||
"settings.block-time": "do {day}, {time}",
|
||||
"settings.block-user": "Opravdu chcete zablokovat uživatele {userName}?",
|
||||
"settings.blocked-users-header": "Blokovaní uživatelé:",
|
||||
"settings.blocked-users-title": "Blokovaní uživatelé",
|
||||
"settings.hidden-user-header": "Skrytí uživatelé:",
|
||||
"settings.hidden-users-title": "Skrytí uživatelé",
|
||||
"settings.hide": "skrýt",
|
||||
"settings.no-blocked-users": "Nejsou zde žádní blokovaní uživatelé",
|
||||
"settings.no-hidden-users": "Nejsou zde žádní skrytí uživatelé",
|
||||
"settings.permanently": "trvale",
|
||||
"settings.show": "zobrazit",
|
||||
"settings.unblock": "odblokovat",
|
||||
"settings.unblock-user": "Chcete odblokovat uživatele {userName}?",
|
||||
"settings.unknown": "neznámý",
|
||||
"sort-by": "Řadit podle",
|
||||
"subscribeByEmail.back": "Zpět",
|
||||
"subscribeByEmail.close": "Zavřít",
|
||||
"subscribeByEmail.email": "Email",
|
||||
"subscribeByEmail.have-been-subscribed": "Byli jste přihlášeni k odběru aktualizací e-mailem",
|
||||
"subscribeByEmail.have-been-unsubscribed": "Byli jste odhlášeni z odběru aktualizací e-mailem",
|
||||
"subscribeByEmail.only-registered-users": "Dostupné pouze pro registrované uživatele",
|
||||
"subscribeByEmail.submit": "Odeslat",
|
||||
"subscribeByEmail.subscribe": "Přihlásit se k odběru",
|
||||
"subscribeByEmail.subscribe-by-email": "Přihlášení k odběru e-mailem",
|
||||
"subscribeByEmail.subscribe-to-replies": "Přihlásit se k odběru odpovědí",
|
||||
"subscribeByEmail.subscribed": "Jste přihlášeni k odběru aktualizací e-mailem",
|
||||
"subscribeByEmail.unsubscribe": "Odhlásit se",
|
||||
"subscribeByRSS.button-title": "Odebírat pomocí RSS",
|
||||
"subscribeByRSS.replies": "Odpovědi",
|
||||
"subscribeByRSS.site": "Web",
|
||||
"subscribeByRSS.thread": "Vlákno",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Zkopírujte a vložte token z e-mailu",
|
||||
"token.expired": "Token expiroval",
|
||||
"token.invalid": "Token je neplatný",
|
||||
"toolbar.attach-image": "Obrázek",
|
||||
"toolbar.bold": "Tučný text {shortcut}",
|
||||
"toolbar.code": "Kód",
|
||||
"toolbar.header": "Nadpis",
|
||||
"toolbar.italic": "Kurzíva {shortcut}",
|
||||
"toolbar.link": "Odkaz {shortcut}",
|
||||
"toolbar.ordered-list": "Číslovaný seznam",
|
||||
"toolbar.quote": "Citace",
|
||||
"toolbar.unordered-list": "Nečíslovaný seznam",
|
||||
"user.comments": "Komentáře",
|
||||
"user.load-more": "Načíst více",
|
||||
"user.my-comments": "Moje komentáře",
|
||||
"vote.controversy": "Kontroverznost: {value}",
|
||||
"vote.downvote": "Udělit negativní hlas",
|
||||
"vote.score": "Výsledek hlasování",
|
||||
"vote.upvote": "Udělit pozitivní hlas"
|
||||
}
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Thread",
|
||||
"subscribeByRSS.title": "RSS-Feed",
|
||||
"token": "Token",
|
||||
"token": "Kopieren und Einfügen des Tokens aus der E-Mail",
|
||||
"token.expired": "Token ist abgelaufen",
|
||||
"token.invalid": "Token ist ungültig",
|
||||
"toolbar.attach-image": "Bild als Anhang, per Drag & Drop oder aus der Zwischenablage einfügen",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Thread",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Token",
|
||||
"token": "Copy and paste the token from the email",
|
||||
"token.expired": "Token is expired",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "Sitio",
|
||||
"subscribeByRSS.thread": "Hilo",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Token",
|
||||
"token": "Kopeeri ja kleebi sümbol e-kirjast.",
|
||||
"token.expired": "Token expirado",
|
||||
"token.invalid": "El token no es válido",
|
||||
"toolbar.attach-image": "Adjunta la imágen, arrastra y suelta, o pega desde el portapapeles",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "Sivusto",
|
||||
"subscribeByRSS.thread": "Teema",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Tunnus",
|
||||
"token": "Kopioi ja liitä tunniste sähköpostista",
|
||||
"token.expired": "Tunnus on vanhentunut",
|
||||
"token.invalid": "Token is invalid",
|
||||
"toolbar.attach-image": "Liitä kuva, vedä ja pudota tai liitä leikepöydältä",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"subscribeByRSS.site": "Site",
|
||||
"subscribeByRSS.thread": "Fil d'actualités",
|
||||
"subscribeByRSS.title": "RSS",
|
||||
"token": "Jeton",
|
||||
"token": "Copiez et collez le jeton de l'e-mail",
|
||||
"token.expired": "Le jeton a expiré",
|
||||
"token.invalid": "Le jeton n'est pas valide",
|
||||
"toolbar.attach-image": "Joindre une image, la glisser-déposer ou la coller à partir du presse-papier",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user