Compare commits

...
Author SHA1 Message Date
Dmitry VerkhoturovandUmputun 8357846818 add test JWT token generation instructions 2023-01-15 12:54:44 -06:00
Matt JacksonandUmputun 31ea91afb8 docs: added Astro w/Svelte Components Integration 2023-01-14 19:12:38 -06:00
Dmitry Verkhoturov 6616541f65 improve frontend documentation
Variables were documented in the documentation but not in the code,
and max_last_comments needed to be documented.
2023-01-10 23:53:26 +01:00
Paul MineevandUmputun 01695822bb fix: calculate correct size when no_footer=true 2023-01-10 11:27:44 -06:00
Paul MineevandUmputun f0186d1aab fix: fix no footer param 2023-01-10 11:27:44 -06:00
Dmitry VerkhoturovandUmputun 41a3359085 add the ability to set the JWS aud per site_id
Without this option, the aud is ignored.
It works only with RPC admin storage.

The shared key returned for all requests with the default shared admin
storage, so enabling that option does not affect it.
2023-01-10 11:24:41 -06:00
Dmitry Verkhoturov d6cce8df2c cleanup of the frontend code
- replace undocumented `substr` with `substring`
- remove unused code
- inline a few variables
- simplify ifs when possible
- improve saveCollapsedComments documentation
- cleanup the unused imports
- remove unused variables and types
2023-01-09 22:16:35 +01:00
Dmitry VerkhoturovandUmputun 596861a594 don't remove the twitter-tweet class from blockquote
This is needed to format the Twitter blockquotes as tweets.
2023-01-09 03:20:54 -06:00
Dmitry VerkhoturovandUmputun 385ea800a4 don't verify subscription email once more for email users
Previous behaviour is preserved for query parameters way of requesting
the subscription. The new behaviour with the possibility to confirm
the email right away without a separate /email/confirm call is enabled
only with request params sent in the request body, which was not a thing
before 27fc339e, which was merged just now and is not part
of any tagged version yet.
2023-01-09 03:17:25 -06:00
Dmitry Verkhoturov 27fc339e36 use the request body for email subscription endpoints
Previously, the endpoints were using query parameters.
After this change, the body is tried to be parsed.
2023-01-08 23:26:06 +01:00
Dmitry VerkhoturovandUmputun 61e2173f25 add e2e tests to makefile
Also, add missing entries to .dockerignore.
2023-01-08 14:31:36 -06:00
Dmitry VerkhoturovandUmputun 13a3fc3d1b move remark42 frontend nvmrc to /frontend/ 2023-01-08 14:31:15 -06:00
Dmitry VerkhoturovandUmputun 6a1b515ea9 add anti-spam documentation
It describes how anti-spam works now and its future.
2023-01-08 14:30:55 -06:00
Dmitry VerkhoturovandUmputun 82f27e6b63 fix typos in frontend code 2023-01-08 12:30:18 -06:00
Paul MineevandUmputun 6ac75031ad fix and optimize apple icon 2023-01-07 18:16:21 -06:00
Dmitry VerkhoturovandUmputun 8b7f1331ee add Apple auth provider frontend support
With distinct logos for light and dark theme from
https://devimages-cdn.apple.com/design/resources/download/Logo-Sign-in-with-Apple.dmg
2023-01-07 18:16:21 -06:00
Dmitry VerkhoturovandUmputun 099aad8475 add apple bad key test, fix key location
Previously, default location was outside of container mount.
2023-01-04 03:54:38 -06:00
Dmitry VerkhoturovandUmputun c1b3fba344 add backend support for Apple auth provider
It's a bit different from other OAuth providers and requires a
different set of options and a private key file.
2023-01-03 23:47:42 -06:00
Dmitry VerkhoturovandUmputun d7e9be99f9 make Close() calls idempotent
Previously, few of them resulted in panics when called more than once.
2023-01-03 01:41:26 -06:00
Dmitry Verkhoturov 067a8bcb21 make the email token tooltip more informative
Previously it said just "Token", but now it will provide more explicit
instructions about copying and pasting the token received by email.

Resolves #1339
2023-01-03 10:52:17 +04:00
Umputun f5569a62f1 add local analytic support 2022-12-25 18:45:31 -06:00
Jakub FridrichandUmputun 1ab1ed8a82 Added cs lang 2022-12-16 12:00:57 -06:00
Pavel MineevandUmputun 8d95baa70f chore: add resize observer mock 2022-12-05 12:03:36 -06:00
Pavel MineevandUmputun 050bce163a refac: use resize observer instead mutation observer in order to resize iframe 2022-12-05 12:03:36 -06:00
Pavel MineevandUmputun 68b84683d0 fix: update iframe size on textarea size change 2022-12-03 12:08:00 -06:00
Pavel MineevandUmputun 6fe373d540 fix: update fe contributing documentation and get dev commands in order 2022-11-30 01:19:51 -06:00
Pavel MineevandUmputun ba19bcc729 fix: update iframe size on mount 2022-11-29 18:12:14 -06:00
Dmitry VerkhoturovandUmputun f1b65db2c7 prefer unused PreferServerCipherSuites param
This is not used since 1.17,
https://github.com/golang/go/commit/9d0819b27ca248f9949e7cf6bf7cb9fe7cf574e8
2022-11-13 12:02:40 -06:00
Dmitry VerkhoturovandUmputun f5f287ef06 bump actions/setup-go to v3 to resolve node12 deprecation note 2022-11-13 12:00:54 -06:00
Igor KlipachandUmputun f161e6033c Removed duplicated linter 'megacheck' 2022-11-13 11:54:26 -06:00
Dmitry VerkhoturovandUmputun c86bff8811 remove duplicate type definitions from function signatures
I haven't found a linter for these, so I had to catch these manually.
I found #757 to fix one of these, and I thought it would be good
to fix everything at once.
2022-11-13 11:51:51 -06:00
Dmitry VerkhoturovandUmputun 596b1045bd allow dots in site id during email user validation 2022-11-03 16:31:00 -05:00
Dmitry VerkhoturovandUmputun 907ca2b590 remove the only generic logger usage
github.com/go-pkgz/lgr should be used instead for consistency
with the rest of the code
2022-11-03 16:30:10 -05:00
Dmitry VerkhoturovandUmputun f1b5469b83 bump github actions versions
Old ones produce warnings due to deprecation of NodeJS 12
2022-11-03 15:20:30 -05:00
Denis HananeinandUmputun 984fbde540 Support no_footer option 2022-10-25 21:42:08 -05:00
Dmitry VerkhoturovandUmputun 2bdc05dd47 remove unused entrypoint.sh
It should be removed after changes in 141c75401.
2022-10-23 12:55:34 -05:00
Dmitry VerkhoturovandUmputun d2ea572abf fix "it's" used in place of "its" 2022-10-20 14:18:21 -05:00
Dmitry VerkhoturovandUmputun 8d5c4cd578 make docker build work on tag again 2022-10-20 14:16:38 -05:00
139 changed files with 1193 additions and 418 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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 }}
+2 -2
View File
@@ -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
View File
@@ -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"]
+3
View File
@@ -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
-1
View File
@@ -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) {
+1 -1
View File
@@ -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
)
+3 -1
View File
@@ -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
View File
@@ -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,
})
}
+20 -3
View File
@@ -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
View File
@@ -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-----
+6
View File
@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgGH2MylyZjjRdauTk
xxXW6p8VSHqIeVRRKSJPg1xn6+KgCgYIKoZIzj0DAQehRANCAAS/mNzQ7aBbIBr3
DiHiJGIDEzi6+q3mmyhH6ZWQWFdFei2qgdyM1V6qtRPVq+yHBNSBebbR4noE/IYO
hMdWYrKn
-----END PRIVATE KEY-----
+1 -1
View File
@@ -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++ {
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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)
+2
View File
@@ -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) {
+5 -5
View File
@@ -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
}
+1 -1
View File
@@ -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) {
+76 -30
View File
@@ -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)
+126 -12
View File
@@ -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) {
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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},
-4
View File
@@ -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{
+1 -1
View File
@@ -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 }
+2
View File
@@ -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" +
+4
View File
@@ -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>&mdash; 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 {
+10 -10
View File
@@ -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)
+7
View File
@@ -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)
+9 -3
View File
@@ -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 {
+2 -2
View File
@@ -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)
+8
View File
@@ -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())
}
+3 -3
View File
@@ -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)
+24
View File
@@ -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")
+7
View File
@@ -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()
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,5 @@
Copyright (c) 2014 HashiCorp, Inc.
Mozilla Public License, version 2.0
1. Definitions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+6 -6
View File
@@ -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
}
};
+1
View File
@@ -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

-2
View File
@@ -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 });
}
+2 -4
View File
@@ -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';
+1 -6
View File
@@ -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 */
@@ -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) => {
+1 -3
View File
@@ -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);
}
+1 -1
View File
@@ -152,7 +152,7 @@
"subscribeByRSS.site": "الموقع",
"subscribeByRSS.thread": "سلسلة ردود",
"subscribeByRSS.title": "RSS",
"token": "رمز",
"token": "انسخ والصق الرمز المميز من البريد الإلكتروني",
"token.expired": "الرمز انتهت صلاحيته",
"token.invalid": "الرمز غير صالح",
"toolbar.attach-image": "أرفق صورةً، أو اسحبها وأسقطها، أو الصقها من الحافظة",
+1 -1
View File
@@ -152,7 +152,7 @@
"subscribeByRSS.site": "Сайт",
"subscribeByRSS.thread": "Гутарка",
"subscribeByRSS.title": "RSS",
"token": "Токен",
"token": "Скапіруйце і ўстаўце токен з электроннага ліста",
"token.expired": "Час дзеяння токена сышоў",
"token.invalid": "Токен несапраўдны",
"toolbar.attach-image": "Прымацаваць выяву, перацягніце або ўстаўце выяву з буфера абмену",
+3 -3
View File
@@ -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": "Вмъкни код",
+1 -1
View File
@@ -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",
+174
View File
@@ -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"
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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ä",
+1 -1
View File
@@ -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