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
8 changes: 5 additions & 3 deletions backend/app/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ type CommonOptionsCommander interface {

// CommonOpts sets externally from main, shared across all commands
type CommonOpts struct {
RemarkURL string
SharedSecret string
Revision string
RemarkURL string
SharedSecret string
Revision string
Premoderation string
}

// SupportCmdOpts is set of commands shared among similar commands like backup/restore and such.
Expand All @@ -53,6 +54,7 @@ func (c *CommonOpts) SetCommon(commonOpts CommonOpts) {
c.RemarkURL = strings.TrimSuffix(commonOpts.RemarkURL, "/") // allow RemarkURL with trailing /
c.SharedSecret = commonOpts.SharedSecret
c.Revision = commonOpts.Revision
c.Premoderation = commonOpts.Premoderation
}

// HandleDeprecatedFlags sets new flags from deprecated and returns their list
Expand Down
1 change: 1 addition & 0 deletions backend/app/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
DisableSignature: s.DisableSignature,
DisableFancyTextFormatting: s.DisableFancyTextFormatting,
ExternalImageProxy: s.ImageProxy.CacheExternal,
Premoderation: api.NewPremoderationStrategy(s.Premoderation),
}

srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore
Expand Down
12 changes: 8 additions & 4 deletions backend/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ type Opts struct {
// SharedSecret is only used in server command, but defined for all commands for historical reasons
SharedSecret string `long:"secret" env:"SECRET" required:"true" description:"the shared secret key used to sign JWT, should be a random, long, hard-to-guess string"`

Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
Premoderation string `long:"premoderation" env:"PREMODERATION" description:"use comments premoderation. Possible values: 'none', 'first', 'all'"`
}

var revision = "unknown"
Expand All @@ -42,12 +43,15 @@ func main() {
setupLog(opts.Dbg)
// commands implements CommonOptionsCommander to allow passing set of extra options defined for all commands
c := command.(cmd.CommonOptionsCommander)

c.SetCommon(cmd.CommonOpts{
RemarkURL: opts.RemarkURL,
SharedSecret: opts.SharedSecret,
Revision: revision,
RemarkURL: opts.RemarkURL,
SharedSecret: opts.SharedSecret,
Revision: revision,
Premoderation: opts.Premoderation,
})
logDeprecatedParams(c.HandleDeprecatedFlags())

err := c.Execute(args)
if err != nil {
log.Printf("[ERROR] failed with %+v", err)
Expand Down
29 changes: 29 additions & 0 deletions backend/app/rest/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type admin struct {
authenticator *auth.Service
readOnlyAge int
migrator *Migrator
premoderation Premoderation
}

type adminStore interface {
Expand All @@ -39,6 +40,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
ApproveComments(locator store.Locator, commentID string, approvePreviousComments bool) (comment store.Comment, err error)
}

// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
Expand Down Expand Up @@ -244,3 +246,30 @@ 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 /comment/{id}/approve?site=siteID&url=post-url - approves comment
func (a *admin) approveCommentCtrl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
log.Printf("[INFO] approving comment %s", id)

var comment store.Comment
var err error
switch a.premoderation {
// if we use the "ApproveFirst" strategy we need to approve all pending comments once one is approved
case PremoderationFirst:
comment, err = a.dataService.ApproveComments(locator, id, true)
default:
comment, err = a.dataService.ApproveComments(locator, id, false)
}

if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't approve comment", rest.ErrInternal)
return
}

log.Printf("[INFO] returning comment %v", comment)
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope))
render.Status(r, http.StatusOK)
render.JSON(w, r, comment)
}
84 changes: 84 additions & 0 deletions backend/app/rest/api/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -880,3 +880,87 @@ func TestAdmin_GetUserInfo(t *testing.T) {
_, code = getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/userX?site=remark42&url=https://radio-t.com/blah", ts.URL))
assert.Equal(t, http.StatusBadRequest, code, "no info about user")
}

func TestAdmin_ApproveComments(t *testing.T) {
ts, srv, teardown := startupT(t, func(srv *Rest) {
srv.Premoderation = PremoderationAll
})
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}

id1, err := srv.DataService.Create(c1)
assert.NoError(t, err)
assert.Equal(t, false, c1.Approved)

req, err := http.NewRequest("PUT", ts.URL+"/api/v1/admin/comment/"+id1+"/approve?site=remark42&url=https://radio-t.com/blah", http.NoBody)
require.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
if err != nil {
t.Logf("Error in the response: %s", err.Error())
}
require.NoError(t, err)

assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))

var c store.Comment
decoder := json.NewDecoder(resp.Body)
err = decoder.Decode(&c)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, true, c.Approved)
t.Logf("Approve comment response: %+v", c)
}

func TestAdmin_ApprovePreviousComments(t *testing.T) {
ts, srv, teardown := startupT(t, func(srv *Rest) {
srv.Premoderation = PremoderationFirst
})
defer teardown()
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c2 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
c3 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}

id1, err := srv.DataService.Create(c1)
assert.NoError(t, err)
id2, err := srv.DataService.Create(c2)
assert.NoError(t, err)
id3, err := srv.DataService.Create(c3)
assert.NoError(t, err)

assert.Equal(t, false, c1.Approved)
assert.Equal(t, false, c2.Approved)
assert.Equal(t, false, c3.Approved)

req, err := http.NewRequest("PUT", ts.URL+"/api/v1/admin/comment/"+id1+"/approve?site=remark42&url=https://radio-t.com/blah", http.NoBody)
require.NoError(t, err)
requireAdminOnly(t, req)
resp, err := sendReq(t, req, adminUmputunToken)
require.NoError(t, err)

assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))

var c store.Comment
decoder := json.NewDecoder(resp.Body)
err = decoder.Decode(&c)
assert.NoError(t, err)
assert.NoError(t, resp.Body.Close())
assert.Equal(t, true, c.Approved)
t.Logf("Approve comment response: %+v", c)

c2, err = srv.pubRest.dataService.Get(c2.Locator, id2, c2.User)
t.Logf("c2: %+v", c2)
assert.NoError(t, err)
assert.Equal(t, true, c2.Approved)

c3, err = srv.pubRest.dataService.Get(c3.Locator, id3, c3.User)
t.Logf("c3: %+v", c3)
assert.NoError(t, err)
assert.Equal(t, true, c3.Approved)
}
37 changes: 35 additions & 2 deletions backend/app/rest/api/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ import (
"github.com/umputun/remark42/backend/app/store/service"
)

type Premoderation string

const (
PremoderationNone Premoderation = "none"
PremoderationFirst Premoderation = "first"
PremoderationAll Premoderation = "all"
)

func NewPremoderationStrategy(str string) Premoderation {
switch strings.ToLower(str) {
case string(PremoderationFirst):
return PremoderationFirst
case string(PremoderationAll):
return PremoderationAll
default:
return PremoderationNone
}
}

// Rest is a rest access server
type Rest struct {
Version string
Expand Down Expand Up @@ -70,6 +89,7 @@ type Rest struct {
DisableSignature bool // prevent signature from being added to headers
DisableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
ExternalImageProxy bool
Premoderation Premoderation

SSLConfig SSLConfig
httpsServer *http.Server
Expand Down Expand Up @@ -304,6 +324,7 @@ func (s *Rest) routes() chi.Router {
radmin.Use(middleware.NoCache, logInfoWithBody)

radmin.Delete("/comment/{id}", s.adminRest.deleteCommentCtrl)
radmin.Put("/comment/{id}/approve", s.adminRest.approveCommentCtrl)
radmin.Put("/user/{userid}", s.adminRest.setBlockCtrl)
radmin.Delete("/user/{userid}", s.adminRest.deleteUserCtrl)
radmin.Get("/user/{userid}", s.adminRest.getUserInfoCtrl)
Expand Down Expand Up @@ -373,6 +394,7 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
imageService: s.ImageService,
commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge,
premoderation: s.Premoderation,
}

privGrp := private{
Expand All @@ -387,6 +409,7 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
remarkURL: s.RemarkURL,
anonVote: s.AnonVote,
disableFancyTextFormatting: s.DisableFancyTextFormatting,
premoderation: s.Premoderation,
}

admGrp := admin{
Expand All @@ -395,6 +418,7 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
cache: s.Cache,
authenticator: s.Authenticator,
readOnlyAge: s.ReadOnlyAge,
premoderation: s.Premoderation,
}

rssGrp := rss{
Expand Down Expand Up @@ -514,10 +538,19 @@ func encodeJSONWithHTML(v interface{}) ([]byte, error) {
return buf.Bytes(), nil
}

func filterComments(comments []store.Comment, fn func(c store.Comment) bool) []store.Comment {
// filterComments applies multiple filters to the comments slice.
// The returned slice contains only those elements that passed all filters.
func filterComments(comments []store.Comment, funcs ...func(c store.Comment) bool) []store.Comment {
filtered := []store.Comment{}
for _, c := range comments {
if fn(c) {
passedAll := true
for _, fn := range funcs {
if !fn(c) {
passedAll = false
break
}
}
if passedAll {
filtered = append(filtered, c)
}
}
Expand Down
40 changes: 40 additions & 0 deletions backend/app/rest/api/rest_private.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type private struct {
remarkURL string
anonVote bool
disableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
premoderation Premoderation
}

// telegramService is a subset of Telegram service used for setting up user telegram notifications
Expand Down Expand Up @@ -149,6 +150,12 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
return
}

comment, err := s.handlePremoderation(comment, user)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "error handling premoderation", rest.ErrPremoderationFailure)
return
}

id, err := s.dataService.Create(comment)
if errors.Is(err, service.ErrRestrictedWordsFound) {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords)
Expand Down Expand Up @@ -177,6 +184,39 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
_ = R.EncodeJSON(w, http.StatusCreated, &finalComment)
}

func (s *private) handlePremoderation(comment store.Comment, user store.User) (store.Comment, error) {
// admin messages are approved by default
if user.Admin {
comment.Approved = true
return comment, nil
}

// if previous comments were approved and the strategy is "first" approve other comments by default
switch s.premoderation {
case PremoderationFirst:
prevComments, err := s.dataService.User(user.SiteID, user.ID, 100, 0, user) // FIXME: check the usage of limit/skip
if err != nil {
if strings.Contains(err.Error(), "no comments") { // it seems we error out if we don't have comments for the user
comment.Approved = false // this is the first message for this user
return comment, nil
}
return store.Comment{}, fmt.Errorf("error accessing user's comments: %s", err.Error())
}
// if there is a previously approved comment then approve the new comment
for _, c := range prevComments {
if c.Approved {
comment.Approved = true
return comment, nil
}
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing fallback case: If PremoderationFirst is active and the user has previous comments but none are approved, the function doesn't explicitly set comment.Approved = false before falling through. The comment will remain with its default zero value, but this should be made explicit for clarity. Add comment.Approved = false after the loop (line 214).

Suggested change
}
}
// Explicitly set Approved to false if no previous comment was approved
comment.Approved = false

Copilot uses AI. Check for mistakes.
case PremoderationAll: // we need to approve every single comment
comment.Approved = false
default: // if no premoderation is applied we mark new comments as approved
comment.Approved = true
}
return comment, nil
}

// PUT /comment/{id}?site=siteID&url=post-url - update comment
func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
edit := struct {
Expand Down
Loading