Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type ServerCommand struct {

Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","`
AnonymousVote bool `long:"anon-vote" env:"ANON_VOTE" description:"enable anonymous votes (works only with VOTES_IP enabled)"`
NeedApproval bool `long:"need-approval" env:"NEED_APPROVAL" description:"require admin approval for new comments to be visible"`
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"`
MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"`
Expand Down Expand Up @@ -520,6 +521,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
ImageService: imageService,
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}, s.getAllowedDomains()),
RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}),
NeedApproval: s.NeedApproval,
}
dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP
dataService.RestrictSameIPVotes.Duration = s.DurationVoteIP
Expand Down
30 changes: 30 additions & 0 deletions backend/app/rest/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type adminStore interface {
SetVerified(siteID, userID string, status bool) error
SetReadOnly(locator store.Locator, status bool) error
SetPin(locator store.Locator, commentID string, status bool) error
SetApproved(locator store.Locator, commentID string, status bool) error
Unapproved(siteID string, limit int) ([]store.Comment, error)
}

// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
Expand Down Expand Up @@ -244,3 +246,31 @@ func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL))
R.RenderJSON(w, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
}

// PUT /approve/{id}?site=siteID&url=post-url&approved=1
// approve/unapprove comment for moderation
func (a *admin) setApprovedCtrl(w http.ResponseWriter, r *http.Request) {
commentID := chi.URLParam(r, "id")
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
approvedStatus := r.URL.Query().Get("approved") == "1"

if err := a.dataService.SetApproved(locator, commentID, approvedStatus); err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set approved status", rest.ErrActionRejected)
return
}
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, lastCommentsScope))
R.RenderJSON(w, R.JSON{"id": commentID, "locator": locator, "approved": approvedStatus})
}

// GET /pending?site=siteID - get pending (unapproved) comments for admin review
func (a *admin) pendingCommentsCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] get pending comments for site %s", siteID)

comments, err := a.dataService.Unapproved(siteID, 100)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get pending comments", rest.ErrInternal)
return
}
R.RenderJSON(w, comments)
}
4 changes: 4 additions & 0 deletions backend/app/rest/api/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ func (s *Rest) routes() chi.Router {
radmin.Get("/deleteme", s.adminRest.deleteMeRequestCtrl)
radmin.Put("/verify/{userid}", s.adminRest.setVerifyCtrl)
radmin.Put("/pin/{id}", s.adminRest.setPinCtrl)
radmin.Put("/approve/{id}", s.adminRest.setApprovedCtrl)
radmin.Get("/pending", s.adminRest.pendingCommentsCtrl)
radmin.Get("/blocked", s.adminRest.blockedUsersCtrl)
radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl)
radmin.Put("/title/{id}", s.adminRest.setTitleCtrl)
Expand Down Expand Up @@ -442,6 +444,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
SimpleView bool `json:"simple_view"`
SendJWTHeader bool `json:"send_jwt_header"`
SubscribersOnly bool `json:"subscribers_only"`
NeedApproval bool `json:"need_approval"`
}{
Version: s.Version,
EditDuration: int(s.DataService.EditDuration.Seconds()),
Expand All @@ -462,6 +465,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
SimpleView: s.SimpleView,
SendJWTHeader: s.SendJWTHeader,
SubscribersOnly: s.SubscribersOnly,
NeedApproval: s.DataService.NeedApproval,
}

cnf.Auth = []string{}
Expand Down
2 changes: 2 additions & 0 deletions backend/app/store/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Comment struct {
Deleted bool `json:"delete,omitempty" bson:"delete"`
Imported bool `json:"imported,omitempty" bson:"imported"`
PostTitle string `json:"title,omitempty" bson:"title"`
Unapproved bool `json:"unapproved,omitempty" bson:"unapproved"` // moderation status, true if pending admin approval
}

// Locator keeps site and url of the post
Expand Down Expand Up @@ -93,6 +94,7 @@ func (c *Comment) PrepareUntrusted() {
c.Pin = false
c.Deleted = false
c.Imported = false
c.Unapproved = false // reset moderation status, will be set by service if NeedApproval is enabled
}

// SetDeleted clears comment info, reset to deleted state. hard flag will clear all user info as well
Expand Down
2 changes: 2 additions & 0 deletions backend/app/store/comment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func TestComment_PrepareUntrusted(t *testing.T) {
Votes: map[string]bool{"uu": true},
Controversy: 123,
Imported: true,
Unapproved: true, // should be reset to false
}

comment.PrepareUntrusted()
Expand All @@ -139,6 +140,7 @@ func TestComment_PrepareUntrusted(t *testing.T) {
assert.Equal(t, User{ID: "username"}, comment.User)
assert.Equal(t, 0., comment.Controversy)
assert.Equal(t, false, comment.Imported)
assert.Equal(t, false, comment.Unapproved) // PrepareUntrusted resets moderation status
}

func TestComment_SetDeleted(t *testing.T) {
Expand Down
67 changes: 65 additions & 2 deletions backend/app/store/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type DataStore struct {
RestrictedWordsMatcher *RestrictedWordsMatcher
ImageService *image.Service
AdminEdits bool // allow admin unlimited edits
NeedApproval bool // require admin approval for new comments to be visible

// granular locks
scopedLocks struct {
Expand Down Expand Up @@ -140,6 +141,11 @@ func (s *DataStore) FindSince(locator store.Locator, sortMethod string, user sto
comments[i] = s.alterComment(c, user)
}

// filter unapproved comments for non-admin users when NeedApproval is enabled
if s.NeedApproval && !user.Admin {
comments = s.filterUnapproved(comments, user)
}

// resort commits if altered
if changedSort {
comments = engine.SortComments(comments, sortMethod)
Expand Down Expand Up @@ -304,6 +310,10 @@ func (s *DataStore) prepareNewComment(comment store.Comment) (store.Comment, err
if comment.Votes == nil {
comment.Votes = make(map[string]bool)
}
// set unapproved if moderation is required
if s.NeedApproval {
comment.Unapproved = true
}
comment.Sanitize() // clear potentially dangerous js from all parts of comment

secret, err := s.getSecret(comment.Locator.SiteID)
Expand Down Expand Up @@ -331,6 +341,18 @@ func (s *DataStore) SetPin(locator store.Locator, commentID string, status bool)
return s.Engine.Update(comment)
}

// SetApproved approve/unapprove comment for moderation
// When approved=true, we set Unapproved=false; when approved=false, we set Unapproved=true
func (s *DataStore) SetApproved(locator store.Locator, commentID string, approved bool) error {
comment, err := s.Engine.Get(engine.GetRequest{Locator: locator, CommentID: commentID})
if err != nil {
return err
}
comment.Unapproved = !approved
comment.Locator = locator
return s.Engine.Update(comment)
}

// VoteReq is the request ot make a vote
type VoteReq struct {
Locator store.Locator
Expand Down Expand Up @@ -956,7 +978,12 @@ func (s *DataStore) User(siteID, userID string, limit, skip int, user store.User
if err != nil {
return comments, err
}
return s.alterComments(comments, user), nil
comments = s.alterComments(comments, user)
// filter unapproved comments for non-admin users when NeedApproval is enabled
if s.NeedApproval && !user.Admin {
comments = s.filterUnapproved(comments, user)
}
return comments, nil
}

// UserCount is comments count by user
Expand All @@ -972,7 +999,30 @@ func (s *DataStore) Last(siteID string, limit int, since time.Time, user store.U
if err != nil {
return comments, err
}
return s.alterComments(comments, user), nil
comments = s.alterComments(comments, user)
// filter unapproved comments for non-admin users when NeedApproval is enabled
if s.NeedApproval && !user.Admin {
comments = s.filterUnapproved(comments, user)
}
return comments, nil
}

// Unapproved gets unapproved comments for site, cross-post. Limited by count.
// This is for admin use only to see pending comments.
func (s *DataStore) Unapproved(siteID string, limit int) ([]store.Comment, error) {
req := engine.FindRequest{Locator: store.Locator{SiteID: siteID}, Limit: limit, Sort: "-time"}
comments, err := s.Engine.Find(req)
if err != nil {
return comments, err
}
// filter to only include unapproved comments
result := make([]store.Comment, 0)
for _, c := range comments {
if c.Unapproved && !c.Deleted {
result = append(result, c)
}
}
return result, nil
}

// Close store service
Expand Down Expand Up @@ -1081,3 +1131,16 @@ func (s *DataStore) getSecret(siteID string) (secret string, err error) {
}
return secret, nil
}

// filterUnapproved removes unapproved comments from non-admin users
// Users can see their own unapproved comments
func (s *DataStore) filterUnapproved(comments []store.Comment, user store.User) []store.Comment {
result := make([]store.Comment, 0, len(comments))
for _, c := range comments {
// show approved comments (Unapproved=false), or user's own unapproved comments
if !c.Unapproved || c.User.ID == user.ID {
result = append(result, c)
}
}
return result
}
115 changes: 115 additions & 0 deletions backend/app/store/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,121 @@ func TestService_Pin(t *testing.T) {
assert.Equal(t, false, c.Pin)
}

func TestService_Approved(t *testing.T) {
eng, teardown := prepStoreEngine(t)
defer teardown()
b := DataStore{Engine: eng, AdminStore: admin.NewStaticKeyStore("secret 123"), NeedApproval: true}

// Use admin user to see all comments including unapproved ones
adminUser := store.User{ID: "admin", Admin: true}
res, err := b.Last("radio-t", 0, time.Time{}, adminUser)
t.Logf("%+v", res[0])
assert.NoError(t, err)
require.Equal(t, 2, len(res))
// Existing comments have Unapproved=false (default), so they are approved
assert.Equal(t, false, res[0].Unapproved)

// Disapprove the comment (set Unapproved=true)
err = b.SetApproved(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, false)
assert.NoError(t, err)

c, err := b.Engine.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID))
assert.NoError(t, err)
assert.Equal(t, true, c.Unapproved)

// Approve the comment (set Unapproved=false)
err = b.SetApproved(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, true)
assert.NoError(t, err)
c, err = b.Engine.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID))
assert.NoError(t, err)
assert.Equal(t, false, c.Unapproved)
}

func TestService_FilterUnapproved(t *testing.T) {
eng, teardown := prepStoreEngine(t)
defer teardown()
b := DataStore{Engine: eng, AdminStore: admin.NewStaticKeyStore("secret 123"), NeedApproval: true}
locator := store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}

// Get all comments as admin
adminUser := store.User{ID: "admin", Admin: true}
allComments, err := b.Find(locator, "-time", adminUser)
assert.NoError(t, err)
require.True(t, len(allComments) > 0)

// Existing comments are approved by default (Unapproved=false)
// Disapprove all except the first comment
for i := 1; i < len(allComments); i++ {
err = b.SetApproved(locator, allComments[i].ID, false)
assert.NoError(t, err)
}

// Admin should see all comments
adminComments, err := b.Find(locator, "-time", adminUser)
assert.NoError(t, err)
assert.Equal(t, len(allComments), len(adminComments))

// Regular user should only see approved comments
regularUser := store.User{ID: "regular-user"}
regularComments, err := b.Find(locator, "-time", regularUser)
assert.NoError(t, err)
assert.Equal(t, 1, len(regularComments))
assert.False(t, regularComments[0].Unapproved)

// Comment author should see their own unapproved comments
commentAuthor := store.User{ID: allComments[1].User.ID}
authorComments, err := b.Find(locator, "-time", commentAuthor)
assert.NoError(t, err)
// Author sees approved + their own unapproved
assert.True(t, len(authorComments) >= 1)
}

func TestService_Unapproved(t *testing.T) {
eng, teardown := prepStoreEngine(t)
defer teardown()
b := DataStore{Engine: eng, AdminStore: admin.NewStaticKeyStore("secret 123"), NeedApproval: true}
defer b.Close()

locator := store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/unapproved-test"}

// Create several comments - they will be marked as unapproved because NeedApproval is true
c1ID, err := b.Create(store.Comment{Text: "comment 1", Locator: locator, User: store.User{ID: "user1", Name: "user1"}})
require.NoError(t, err)

c2ID, err := b.Create(store.Comment{Text: "comment 2", Locator: locator, User: store.User{ID: "user2", Name: "user2"}})
require.NoError(t, err)

// Verify comments are unapproved
adminUser := store.User{Admin: true}
allComments, err := b.Find(locator, "-time", adminUser)
require.NoError(t, err)
require.Equal(t, 2, len(allComments))
assert.True(t, allComments[0].Unapproved)
assert.True(t, allComments[1].Unapproved)

// Approve one comment
err = b.SetApproved(locator, c1ID, true)
require.NoError(t, err)

// Get unapproved comments - includes all unapproved from site
unapproved, err := b.Unapproved("radio-t", 100)
require.NoError(t, err)
// Find our unapproved comment
found := false
for _, c := range unapproved {
if c.ID == c2ID {
found = true
break
}
}
assert.True(t, found, "should find our unapproved comment")

// Verify approved comment is not in the list
for _, c := range unapproved {
assert.NotEqual(t, c1ID, c.ID, "approved comment should not be in unapproved list")
}
}

func TestService_EditComment(t *testing.T) {
eng, teardown := prepStoreEngine(t)
defer teardown()
Expand Down
1 change: 1 addition & 0 deletions frontend/apps/remark42/app/__stubs__/static-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ beforeEach(() => {
email_notifications: false,
telegram_notifications: false,
emoji_enabled: true,
need_approval: false,
};
});
6 changes: 6 additions & 0 deletions frontend/apps/remark42/app/common/api.getPendingComments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Comment } from './types';
import { apiFetcher } from './fetcher';

export function getPendingComments(siteId: string): Promise<Comment[]> {
return apiFetcher.get('/admin/pending', { site: siteId });
}
24 changes: 22 additions & 2 deletions frontend/apps/remark42/app/common/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getUserComments } from './api';
import { apiFetcher } from './fetcher';
import { getUserComments, approveComment, disapproveComment } from './api';
import { apiFetcher, adminFetcher } from './fetcher';

describe('getUserComments', () => {
it('should call apiFetcher.get with /comments endpoint and default skip and limit query params', () => {
Expand Down Expand Up @@ -29,3 +29,23 @@ describe('getUserComments', () => {
});
});
});

describe('approveComment', () => {
it('should call adminFetcher.put with /approve/{id} endpoint and approved=1', () => {
const adminFetcherSpy = jest.spyOn(adminFetcher, 'put').mockResolvedValue(undefined);
const commentId = 'comment-123';

approveComment(commentId);
expect(adminFetcherSpy).toHaveBeenCalledWith(`/approve/${commentId}`, expect.objectContaining({ approved: 1 }));
});
});

describe('disapproveComment', () => {
it('should call adminFetcher.put with /approve/{id} endpoint and approved=0', () => {
const adminFetcherSpy = jest.spyOn(adminFetcher, 'put').mockResolvedValue(undefined);
const commentId = 'comment-123';

disapproveComment(commentId);
expect(adminFetcherSpy).toHaveBeenCalledWith(`/approve/${commentId}`, expect.objectContaining({ approved: 0 }));
});
});
Loading