From f254af743ea4f722d3e3e073f5732eee60eb491f Mon Sep 17 00:00:00 2001 From: "Ayoub G." Date: Sat, 6 Dec 2025 02:13:24 -0700 Subject: [PATCH 1/8] adding approval of comments --- backend/app/cmd/server.go | 2 + backend/app/rest/api/admin.go | 16 ++++ backend/app/rest/api/rest.go | 1 + backend/app/store/comment.go | 2 + backend/app/store/comment_test.go | 2 + backend/app/store/service/service.go | 45 ++++++++++- backend/app/store/service/service_test.go | 63 +++++++++++++++ frontend/apps/remark42/app/common/api.test.ts | 24 +++++- frontend/apps/remark42/app/common/api.ts | 6 ++ frontend/apps/remark42/app/common/types.ts | 2 + .../components/comment/comment-actions.tsx | 9 +++ .../app/components/comment/comment.test.tsx | 32 ++++++++ .../app/components/comment/comment.tsx | 22 ++++++ .../components/comment/connected-comment.tsx | 10 ++- .../app/store/comments/actions.test.ts | 78 ++++++++++++++++++- .../remark42/app/store/comments/actions.ts | 14 ++++ frontend/packages/api/clients/public.ts | 4 +- 17 files changed, 324 insertions(+), 8 deletions(-) diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 9a6256d116..14a19c9473 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -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"` @@ -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 diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 2902566777..89334bb805 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -39,6 +39,7 @@ 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 } // DELETE /comment/{id}?site=siteID&url=post-url - removes comment @@ -244,3 +245,18 @@ 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}) +} diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index f351dffb34..66f20ee6ed 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -310,6 +310,7 @@ 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("/blocked", s.adminRest.blockedUsersCtrl) radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl) radmin.Put("/title/{id}", s.adminRest.setTitleCtrl) diff --git a/backend/app/store/comment.go b/backend/app/store/comment.go index 6527088570..2167fbab24 100644 --- a/backend/app/store/comment.go +++ b/backend/app/store/comment.go @@ -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"` + Approved bool `json:"approved,omitempty" bson:"approved"` // moderation status, true if approved by admin } // Locator keeps site and url of the post @@ -93,6 +94,7 @@ func (c *Comment) PrepareUntrusted() { c.Pin = false c.Deleted = false c.Imported = false + c.Approved = false // new comments need approval when NeedApproval is enabled } // SetDeleted clears comment info, reset to deleted state. hard flag will clear all user info as well diff --git a/backend/app/store/comment_test.go b/backend/app/store/comment_test.go index ce48d37e53..936498d7db 100644 --- a/backend/app/store/comment_test.go +++ b/backend/app/store/comment_test.go @@ -124,6 +124,7 @@ func TestComment_PrepareUntrusted(t *testing.T) { Votes: map[string]bool{"uu": true}, Controversy: 123, Imported: true, + Approved: true, // should be reset to false } comment.PrepareUntrusted() @@ -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.Approved) // new comments need approval when NeedApproval is enabled } func TestComment_SetDeleted(t *testing.T) { diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index c1019109ba..0ba296b6f7 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -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 { @@ -140,6 +141,12 @@ 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 + // admins see all comments, users see approved comments + their own unapproved comments + if s.NeedApproval && !user.Admin { + comments = s.filterUnapproved(comments, user) + } + // resort commits if altered if changedSort { comments = engine.SortComments(comments, sortMethod) @@ -331,6 +338,17 @@ func (s *DataStore) SetPin(locator store.Locator, commentID string, status bool) return s.Engine.Update(comment) } +// SetApproved approve/unapprove comment for moderation +func (s *DataStore) SetApproved(locator store.Locator, commentID string, status bool) error { + comment, err := s.Engine.Get(engine.GetRequest{Locator: locator, CommentID: commentID}) + if err != nil { + return err + } + comment.Approved = status + comment.Locator = locator + return s.Engine.Update(comment) +} + // VoteReq is the request ot make a vote type VoteReq struct { Locator store.Locator @@ -956,7 +974,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 @@ -972,7 +995,12 @@ 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 } // Close store service @@ -1081,3 +1109,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, or user's own unapproved comments + if c.Approved || c.User.ID == user.ID { + result = append(result, c) + } + } + return result +} diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index 1c203669b5..8a5ec0688f 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -802,6 +802,69 @@ 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)) + assert.Equal(t, false, res[0].Approved) + + 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, true, c.Approved) + + 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, false, c.Approved) +} + +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) + + // Approve only the first comment + err = b.SetApproved(locator, allComments[0].ID, true) + 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.True(t, regularComments[0].Approved) + + // 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_EditComment(t *testing.T) { eng, teardown := prepStoreEngine(t) defer teardown() diff --git a/frontend/apps/remark42/app/common/api.test.ts b/frontend/apps/remark42/app/common/api.test.ts index 3c9f6c99f7..bdf907367d 100644 --- a/frontend/apps/remark42/app/common/api.test.ts +++ b/frontend/apps/remark42/app/common/api.test.ts @@ -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', () => { @@ -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 })); + }); +}); diff --git a/frontend/apps/remark42/app/common/api.ts b/frontend/apps/remark42/app/common/api.ts index 2eb2f0ed5a..275251b60b 100644 --- a/frontend/apps/remark42/app/common/api.ts +++ b/frontend/apps/remark42/app/common/api.ts @@ -136,6 +136,12 @@ export const pinComment = (id: Comment['id']): Promise => adminFetcher.put export const unpinComment = (id: Comment['id']): Promise => adminFetcher.put(`/pin/${id}`, { url, pin: 0 }); +export const approveComment = (id: Comment['id']): Promise => + adminFetcher.put(`/approve/${id}`, { url, approved: 1 }); + +export const disapproveComment = (id: Comment['id']): Promise => + adminFetcher.put(`/approve/${id}`, { url, approved: 0 }); + export const setVerifiedStatus = (id: User['id']): Promise => adminFetcher.put(`/verify/${id}`, { verified: 1 }); export const removeVerifiedStatus = (id: User['id']): Promise => diff --git a/frontend/apps/remark42/app/common/types.ts b/frontend/apps/remark42/app/common/types.ts index fd18f8ebc3..40fc050b6c 100644 --- a/frontend/apps/remark42/app/common/types.ts +++ b/frontend/apps/remark42/app/common/types.ts @@ -63,6 +63,8 @@ export interface Comment { delete?: boolean; /** post title */ title?: string; + /** approved status for moderation, read only */ + approved?: boolean; /** * @ClientOnly defines whether comments was hidden (deleted) * diff --git a/frontend/apps/remark42/app/components/comment/comment-actions.tsx b/frontend/apps/remark42/app/components/comment/comment-actions.tsx index d3fca73e41..2f4dffc19c 100644 --- a/frontend/apps/remark42/app/components/comment/comment-actions.tsx +++ b/frontend/apps/remark42/app/components/comment/comment-actions.tsx @@ -14,6 +14,7 @@ export type Props = { admin: boolean | undefined; currentUser: boolean | undefined; pinned: boolean | undefined; + approved: boolean | undefined; copied: boolean | undefined; bannedUser: boolean | undefined; readOnly: boolean | undefined; @@ -25,6 +26,7 @@ export type Props = { onToggleEditing(): void; onDelete(): void; onTogglePin(): void; + onToggleApproval(): void; onToggleReplying(): void; onHideUser(): void; onBlockUser(ttl: BlockTTL): void; @@ -35,6 +37,7 @@ export type Props = { export function CommentActions({ admin, pinned, + approved, copied, readOnly, editable, @@ -47,6 +50,7 @@ export function CommentActions({ onToggleEditing, onDelete, onTogglePin, + onToggleApproval, onToggleReplying, onDisableEditing, onHideUser, @@ -99,6 +103,9 @@ export function CommentActions({ + {bannedUser ? ( {bannedUser ? ( - + {needApproval && ( + + )} {bannedUser ? (