diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 733061e4..ed951b7c 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -805,7 +805,7 @@ func (s *ServerCommand) makeAuthenticator(ds *service.DataStore, avas avatar.Sto c.User.SetAdmin(ds.IsAdmin(c.Audience, c.User.ID)) c.User.SetBoolAttr("blocked", ds.IsBlocked(c.Audience, c.User.ID)) var err error - c.User.Email, err = ds.GetUserEmail(store.Locator{SiteID: c.Audience}, c.User.ID) + c.User.Email, err = ds.GetUserEmail(c.Audience, c.User.ID) if err != nil { log.Printf("[WARN] can't read email for %s, %v", c.User.ID, err) } diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go index 9bb8f8cb..a6053d98 100644 --- a/backend/app/notify/email.go +++ b/backend/app/notify/email.go @@ -188,7 +188,7 @@ func (e *Email) Send(ctx context.Context, req Request) (err error) { if req.Verification.Token != "" { log.Printf("[DEBUG] send verification via %s, user %s", e, req.Verification.User) - msg, err = e.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.Locator.SiteID) + msg, err = e.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.SiteID) if err != nil { return err } diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index e7f80a80..66de0cde 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -32,7 +32,7 @@ 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(locator store.Locator, userID string) (string, error) + GetUserEmail(siteID string, userID string) (string, error) } // Request notification either about comment or about particular user verification @@ -45,9 +45,9 @@ type Request struct { // VerificationMetadata required to send notify method verification message type VerificationMetadata struct { - Locator store.Locator // only SiteID is used - User string - Token string + SiteID string + User string + Token string } const defaultQueueSize = 100 @@ -82,7 +82,7 @@ func (s *Service) Submit(req Request) { if s.dataService != nil && req.Comment.ParentID != "" { if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil { req.parent = p - req.Email, err = s.dataService.GetUserEmail(req.Comment.Locator, p.User.ID) + req.Email, err = s.dataService.GetUserEmail(req.Comment.Locator.SiteID, p.User.ID) if err != nil { log.Printf("[WARN] can't read email for %s, %v", p.User.ID, err) } diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go index 271067e9..2797e48a 100644 --- a/backend/app/notify/notify_test.go +++ b/backend/app/notify/notify_test.go @@ -123,6 +123,6 @@ func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, return res, nil } -func (m mockStore) GetUserEmail(_ store.Locator, _ string) (string, error) { +func (m mockStore) GetUserEmail(_ string, _ string) (string, error) { return "", errors.New("no such user") } diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index 8255b1ac..83036242 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -48,9 +48,9 @@ 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(locator store.Locator, userID string) (string, error) - SetUserEmail(locator store.Locator, userID string, value string) (string, error) - DeleteUserDetail(locator store.Locator, userID string, detail engine.UserDetail) error + GetUserEmail(siteID string, userID string) (string, error) + SetUserEmail(siteID string, userID string, value string) (string, error) + DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error ValidateComment(c *store.Comment) error IsVerified(siteID string, userID string) bool IsReadOnly(locator store.Locator) bool @@ -202,6 +202,14 @@ func (s *private) userInfoCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) if siteID := r.URL.Query().Get("site"); siteID != "" { user.Verified = s.dataService.IsVerified(siteID, user.ID) + + email, err := s.dataService.GetUserEmail(siteID, user.ID) + if err != nil { + log.Printf("[WARN] can't read email for %s, %v", user.ID, err) + } + if len(email) > 0 { + user.EmailSubscription = true + } } render.JSON(w, r, user) @@ -253,7 +261,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) { func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) siteID := r.URL.Query().Get("site") - address, err := s.dataService.GetUserEmail(store.Locator{SiteID: siteID}, user.ID) + address, err := s.dataService.GetUserEmail(siteID, user.ID) if err != nil { log.Printf("[WARN] can't read email for %s, %v", user.ID, err) } @@ -271,7 +279,7 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "address parameter is required", rest.ErrInternal) return } - existingAddress, err := s.dataService.GetUserEmail(store.Locator{SiteID: siteID}, user.ID) + existingAddress, err := s.dataService.GetUserEmail(siteID, user.ID) if err != nil { log.Printf("[WARN] can't read email for %s, %v", user.ID, err) } @@ -299,9 +307,9 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque notify.Request{ Email: address, Verification: notify.VerificationMetadata{ - Locator: store.Locator{SiteID: siteID}, - User: user.Name, - Token: tkn, + SiteID: siteID, + User: user.Name, + Token: tkn, }, }, ) @@ -318,7 +326,7 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) return } user := rest.MustGetUserInfo(r) - locator := store.Locator{SiteID: r.URL.Query().Get("site")} + siteID := r.URL.Query().Get("site") confClaims, err := s.authenticator.TokenService().Parse(tkn) if err != nil { @@ -340,7 +348,7 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) log.Printf("[DEBUG] set email for user %s", user.ID) - val, err := s.dataService.SetUserEmail(locator, user.ID, address) + val, err := s.dataService.SetUserEmail(siteID, user.ID, address) if err != nil { code := parseError(err, rest.ErrInternal) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code) @@ -368,7 +376,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) { rest.SendErrorHTML(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal) return } - locator := store.Locator{SiteID: r.URL.Query().Get("site")} + siteID := r.URL.Query().Get("site") confClaims, err := s.authenticator.TokenService().Parse(tkn) if err != nil { @@ -389,7 +397,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) { userID := elems[0] address := elems[1] - existingAddress, err := s.dataService.GetUserEmail(locator, userID) + existingAddress, err := s.dataService.GetUserEmail(siteID, userID) if err != nil { log.Printf("[WARN] can't read email for %s, %v", userID, err) } @@ -404,7 +412,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[DEBUG] unsubscribe user %s", userID) - if err := s.dataService.DeleteUserDetail(locator, userID, engine.UserEmail); err != nil { + if err := s.dataService.DeleteUserDetail(siteID, userID, engine.UserEmail); err != nil { code := parseError(err, rest.ErrInternal) rest.SendErrorHTML(w, r, http.StatusBadRequest, err, "can't delete email for user", code) return @@ -438,10 +446,10 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) { // DELETE /email?site=siteID - removes user's email func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) - locator := store.Locator{SiteID: r.URL.Query().Get("site")} + siteID := r.URL.Query().Get("site") log.Printf("[DEBUG] remove email for user %s", user.ID) - if err := s.dataService.DeleteUserDetail(locator, user.ID, engine.UserEmail); err != nil { + if err := s.dataService.DeleteUserDetail(siteID, user.ID, engine.UserEmail); err != nil { code := parseError(err, rest.ErrInternal) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code) return diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 9c940665..9997f210 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -644,6 +644,21 @@ func TestRest_EmailNotification(t *testing.T) { require.NoError(t, err) 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", nil) + require.NoError(t, err) + req.Header.Add("X-JWT", devToken) + resp, err = client.Do(req) + require.NoError(t, err) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + var user store.User + err = json.Unmarshal(body, &user) + 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) + // create child comment from another user, email notification expected req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf( `{"text": "test 789", diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index afe815c9..7b90c4af 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -160,10 +160,10 @@ func (s *DataStore) Put(locator store.Locator, comment store.Comment) error { } // GetUserEmail gets user email -func (s *DataStore) GetUserEmail(locator store.Locator, userID string) (string, error) { +func (s *DataStore) GetUserEmail(siteID string, userID string) (string, error) { res, err := s.Engine.UserDetail(engine.UserDetailRequest{ Detail: engine.UserEmail, - Locator: locator, + Locator: store.Locator{SiteID: siteID}, UserID: userID, }) if err != nil { @@ -176,10 +176,10 @@ func (s *DataStore) GetUserEmail(locator store.Locator, userID string) (string, } // SetUserEmail sets user email -func (s *DataStore) SetUserEmail(locator store.Locator, userID string, value string) (string, error) { +func (s *DataStore) SetUserEmail(siteID string, userID string, value string) (string, error) { res, err := s.Engine.UserDetail(engine.UserDetailRequest{ Detail: engine.UserEmail, - Locator: locator, + Locator: store.Locator{SiteID: siteID}, UserID: userID, Update: value, }) @@ -193,9 +193,9 @@ func (s *DataStore) SetUserEmail(locator store.Locator, userID string, value str } // DeleteUserDetail deletes user detail -func (s *DataStore) DeleteUserDetail(locator store.Locator, userID string, detail engine.UserDetail) error { +func (s *DataStore) DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error { return s.Engine.Delete(engine.DeleteRequest{ - Locator: locator, + Locator: store.Locator{SiteID: siteID}, UserID: userID, UserDetail: detail, }) diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index cc74b2ab..3aa4e5af 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -842,31 +842,31 @@ func TestService_UserDetailsOperations(t *testing.T) { AdminStore: admin.NewStaticKeyStore("secret 123")} // add single valid entry - result, err := b.SetUserEmail(store.Locator{SiteID: "radio-t"}, "u1", "test@example.com") + result, err := b.SetUserEmail("radio-t", "u1", "test@example.com") assert.NoError(t, err, "No error inserting entry expected") assert.Equal(t, "test@example.com", result) // read valid entry back - result, err = b.GetUserEmail(store.Locator{SiteID: "radio-t"}, "u1") + result, err = b.GetUserEmail("radio-t", "u1") assert.NoError(t, err, "No error reading entry expected") assert.Equal(t, "test@example.com", result) // delete existing entry - err = b.DeleteUserDetail(store.Locator{SiteID: "radio-t"}, "u1", engine.UserEmail) + err = b.DeleteUserDetail("radio-t", "u1", engine.UserEmail) assert.NoError(t, err, "No error deleting entry expected") // read deleted entry - result, err = b.GetUserEmail(store.Locator{SiteID: "radio-t"}, "u1") + result, err = b.GetUserEmail("radio-t", "u1") assert.NoError(t, err, "No error reading entry expected") assert.Empty(t, result) // insert entry with invalid site_id - result, err = b.SetUserEmail(store.Locator{SiteID: "bad-site"}, "u3", "does_not_matter@example.com") + result, err = b.SetUserEmail("bad-site", "u3", "does_not_matter@example.com") assert.Error(t, err, "Site not found") assert.Empty(t, result) // read entry with invalid site_id - result, err = b.GetUserEmail(store.Locator{SiteID: "bad-site"}, "u3") + result, err = b.GetUserEmail("bad-site", "u3") assert.Error(t, err, "Site not found") assert.Empty(t, result) } diff --git a/backend/app/store/user.go b/backend/app/store/user.go index bb1f5764..b9bded97 100644 --- a/backend/app/store/user.go +++ b/backend/app/store/user.go @@ -15,14 +15,15 @@ import ( // User holds user-related info type User struct { - Name string `json:"name"` - ID string `json:"id"` - Picture string `json:"picture"` - IP string `json:"ip,omitempty"` - Admin bool `json:"admin"` - Blocked bool `json:"block,omitempty"` - Verified bool `json:"verified,omitempty"` - SiteID string `json:"site_id,omitempty"` + Name string `json:"name"` + ID string `json:"id"` + Picture string `json:"picture"` + IP string `json:"ip,omitempty"` + Admin bool `json:"admin"` + Blocked bool `json:"block,omitempty"` + Verified bool `json:"verified,omitempty"` + EmailSubscription bool `json:"email_subscription,omitempty"` + SiteID string `json:"site_id,omitempty"` } var reValidSha = regexp.MustCompile("^[a-fA-F0-9]{40}$")