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..a8b929713a 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -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 @@ -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) +} diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index f351dffb34..9461cf459f 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -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) @@ -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()), @@ -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{} diff --git a/backend/app/store/comment.go b/backend/app/store/comment.go index 6527088570..1a20eac313 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"` + Unapproved bool `json:"unapproved,omitempty" bson:"unapproved"` // moderation status, true if pending admin approval } // 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.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 diff --git a/backend/app/store/comment_test.go b/backend/app/store/comment_test.go index ce48d37e53..c700655e95 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, + Unapproved: 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.Unapproved) // PrepareUntrusted resets moderation status } func TestComment_SetDeleted(t *testing.T) { diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index c1019109ba..a09ab3bff8 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,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) @@ -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) @@ -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 @@ -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 @@ -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 @@ -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 +} diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index 1c203669b5..17dc98ab1d 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -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() diff --git a/frontend/apps/remark42/app/__stubs__/static-config.ts b/frontend/apps/remark42/app/__stubs__/static-config.ts index 2145a4757f..92730d0a5d 100644 --- a/frontend/apps/remark42/app/__stubs__/static-config.ts +++ b/frontend/apps/remark42/app/__stubs__/static-config.ts @@ -18,5 +18,6 @@ beforeEach(() => { email_notifications: false, telegram_notifications: false, emoji_enabled: true, + need_approval: false, }; }); diff --git a/frontend/apps/remark42/app/common/api.getPendingComments.ts b/frontend/apps/remark42/app/common/api.getPendingComments.ts new file mode 100644 index 0000000000..a5de0fac53 --- /dev/null +++ b/frontend/apps/remark42/app/common/api.getPendingComments.ts @@ -0,0 +1,6 @@ +import { Comment } from './types'; +import { apiFetcher } from './fetcher'; + +export function getPendingComments(siteId: string): Promise { + return apiFetcher.get('/admin/pending', { site: siteId }); +} 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/static-store.ts b/frontend/apps/remark42/app/common/static-store.ts index bced7b79f2..e287450846 100644 --- a/frontend/apps/remark42/app/common/static-store.ts +++ b/frontend/apps/remark42/app/common/static-store.ts @@ -29,5 +29,6 @@ export const StaticStore: StaticStoreType = { email_notifications: false, telegram_notifications: false, emoji_enabled: false, + need_approval: false, }, }; diff --git a/frontend/apps/remark42/app/common/types.ts b/frontend/apps/remark42/app/common/types.ts index fd18f8ebc3..a8b56dfa74 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; + /** unapproved status for moderation (pending approval), read only */ + unapproved?: boolean; /** * @ClientOnly defines whether comments was hidden (deleted) * @@ -122,6 +124,7 @@ export interface Config { email_notifications: boolean; telegram_notifications: boolean; emoji_enabled: boolean; + need_approval: boolean; } export type Sorting = '-time' | '+time' | '-active' | '+active' | '-score' | '+score' | '-controversy' | '+controversy'; diff --git a/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx b/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx index 648dd85326..8d5ae2f500 100644 --- a/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx +++ b/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx @@ -7,6 +7,8 @@ import { fireEvent, screen, waitFor } from '@testing-library/preact'; function getProps(): Props { return { pinned: false, + unapproved: false, + needApproval: false, admin: false, currentUser: false, copied: false, @@ -18,6 +20,7 @@ function getProps(): Props { onDelete: jest.fn(), onToggleEditing: jest.fn(), onTogglePin: jest.fn(), + onToggleApproval: jest.fn(), onToggleReplying: jest.fn(), onHideUser: jest.fn(), onBlockUser: jest.fn(), @@ -144,12 +147,15 @@ describe('', () => { it('should render admin actions in right order', () => { props.admin = true; + props.needApproval = true; render(); expect(screen.getByTestId('comment-actions-additional').children[0]).toHaveTextContent('Hide'); expect(screen.getByTestId('comment-actions-additional').children[1]).toHaveTextContent('Copy'); expect(screen.getByTestId('comment-actions-additional').children[2]).toHaveTextContent('Pin'); - expect(screen.getByTestId('comment-actions-additional').children[3]).toHaveTextContent('Block'); - expect(screen.getByTestId('comment-actions-additional').children[4]).toHaveTextContent('Delete'); + // unapproved=false (default) means comment is approved, so button shows "Disapprove" + expect(screen.getByTestId('comment-actions-additional').children[3]).toHaveTextContent('Disapprove'); + expect(screen.getByTestId('comment-actions-additional').children[4]).toHaveTextContent('Block'); + expect(screen.getByTestId('comment-actions-additional').children[5]).toHaveTextContent('Delete'); }); it('calls `onToggleEditing` when edit button is pressed', () => { diff --git a/frontend/apps/remark42/app/components/comment/comment-actions.tsx b/frontend/apps/remark42/app/components/comment/comment-actions.tsx index d3fca73e41..0ed7150272 100644 --- a/frontend/apps/remark42/app/components/comment/comment-actions.tsx +++ b/frontend/apps/remark42/app/components/comment/comment-actions.tsx @@ -14,6 +14,8 @@ export type Props = { admin: boolean | undefined; currentUser: boolean | undefined; pinned: boolean | undefined; + unapproved: boolean | undefined; + needApproval: boolean | undefined; copied: boolean | undefined; bannedUser: boolean | undefined; readOnly: boolean | undefined; @@ -25,6 +27,7 @@ export type Props = { onToggleEditing(): void; onDelete(): void; onTogglePin(): void; + onToggleApproval(): void; onToggleReplying(): void; onHideUser(): void; onBlockUser(ttl: BlockTTL): void; @@ -35,6 +38,8 @@ export type Props = { export function CommentActions({ admin, pinned, + unapproved, + needApproval, copied, readOnly, editable, @@ -47,6 +52,7 @@ export function CommentActions({ onToggleEditing, onDelete, onTogglePin, + onToggleApproval, onToggleReplying, onDisableEditing, onHideUser, @@ -99,6 +105,11 @@ export function CommentActions({ + {needApproval && ( + + )} {bannedUser ? (