Replace strings.Split(RemoteAddr, ":") with net.SplitHostPort for correct IPv6 address extraction in vote deduplication and comment IP tracking. Harden image proxy: add SSRF-safe transport blocking private/reserved IPs at connection time with DNS rebinding protection, sanitize error messages to prevent information leakage, add response size limit via io.LimitReader. Fix shadowed error variables in BlockedUsers, SetTitle, and Delete methods. Exclude gosec taint analysis false positives at linter config level.
247 lines
9.9 KiB
Go
247 lines
9.9 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-pkgz/auth/v2"
|
|
cache "github.com/go-pkgz/lcw/v2"
|
|
log "github.com/go-pkgz/lgr"
|
|
R "github.com/go-pkgz/rest"
|
|
|
|
"github.com/umputun/remark42/backend/app/rest"
|
|
"github.com/umputun/remark42/backend/app/store"
|
|
"github.com/umputun/remark42/backend/app/store/engine"
|
|
)
|
|
|
|
// admin provides router for all requests available for admin users only
|
|
type admin struct {
|
|
dataService adminStore
|
|
cache LoadingCache
|
|
authenticator *auth.Service
|
|
readOnlyAge int
|
|
migrator *Migrator
|
|
}
|
|
|
|
type adminStore interface {
|
|
Delete(locator store.Locator, commentID string, mode store.DeleteMode) error
|
|
DeleteUser(siteID, userID string, mode store.DeleteMode) error
|
|
DeleteUserDetail(siteID, userID string, detail engine.UserDetail) error
|
|
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
|
|
IsBlocked(siteID, userID string) bool
|
|
SetBlock(siteID, userID string, status bool, ttl time.Duration) error
|
|
BlockedUsers(siteID string) ([]store.BlockedUser, error)
|
|
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
|
|
SetTitle(locator store.Locator, commentID string) (comment store.Comment, err error)
|
|
SetVerified(siteID, userID string, status bool) error
|
|
SetReadOnly(locator store.Locator, status bool) error
|
|
SetPin(locator store.Locator, commentID string, status bool) error
|
|
}
|
|
|
|
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
|
|
func (a *admin) deleteCommentCtrl(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] delete comment %s", id)
|
|
|
|
err := a.dataService.Delete(locator, id, store.SoftDelete)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete comment", rest.ErrInternal)
|
|
return
|
|
}
|
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope))
|
|
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
|
|
}
|
|
|
|
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
|
|
func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "userid")
|
|
siteID := r.URL.Query().Get("site")
|
|
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
|
|
|
|
if err := a.dataService.DeleteUser(siteID, userID, store.HardDelete); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete user", rest.ErrInternal)
|
|
return
|
|
}
|
|
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
|
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID})
|
|
}
|
|
|
|
// GET /user/{userid}?site=side-id - get user info for requested userid
|
|
func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "userid")
|
|
siteID := r.URL.Query().Get("site")
|
|
log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
|
|
|
|
ucomments, err := a.dataService.User(siteID, userID, 1, 0, rest.GetUserOrEmpty(r))
|
|
if err != nil || len(ucomments) == 0 {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get user info", rest.ErrInternal)
|
|
return
|
|
}
|
|
R.RenderJSON(w, ucomments[0].User)
|
|
}
|
|
|
|
// GET /deleteme?token=jwt - delete all user comments and details by user's request. Gets info about deleted used from provided token
|
|
// request made GET to allow direct click from the email sent by user
|
|
func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
|
token := r.URL.Query().Get("token")
|
|
|
|
claims, err := a.authenticator.TokenService().Parse(token)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't process token", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
|
|
log.Printf("[INFO] delete all user comments by request for %s, site %s", claims.User.ID, claims.Audience)
|
|
|
|
// deleteme set by deleteMeCtrl, this check just to make sure we not trying to delete with leaked token
|
|
if !claims.User.BoolAttr("delete_me") {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("forbidden"), "can't use provided token", rest.ErrNoAccess)
|
|
return
|
|
}
|
|
|
|
// audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
|
|
if len(claims.Audience) != 1 {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("bad request"), "can't process token, claims.Audience expected to be a single element but it's not", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
|
|
audience := claims.Audience[0]
|
|
|
|
if err = a.dataService.DeleteUserDetail(audience, claims.User.ID, engine.AllUserDetails); err != nil {
|
|
code := parseError(err, rest.ErrInternal)
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code)
|
|
return
|
|
}
|
|
|
|
if err = a.dataService.DeleteUser(audience, claims.User.ID, store.HardDelete); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user", rest.ErrNoAccess)
|
|
return
|
|
}
|
|
|
|
if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil {
|
|
avatarStore := a.authenticator.AvatarProxy().Store
|
|
if err = avatarStore.Remove(path.Base(claims.User.Picture)); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar", rest.ErrInternal)
|
|
return
|
|
}
|
|
}
|
|
|
|
a.cache.Flush(cache.Flusher(audience).Scopes(audience, claims.User.ID, lastCommentsScope))
|
|
R.RenderJSON(w, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
|
|
}
|
|
|
|
// PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user
|
|
func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "userid")
|
|
siteID := r.URL.Query().Get("site")
|
|
blockStatus := r.URL.Query().Get("block") == "1"
|
|
|
|
ttl := time.Duration(0) // unlimited duration by default
|
|
if ttlParam := r.URL.Query().Get("ttl"); ttlParam != "" {
|
|
if d, err := time.ParseDuration(ttlParam); err == nil {
|
|
ttl = d
|
|
}
|
|
}
|
|
|
|
if err := a.dataService.SetBlock(siteID, userID, blockStatus, ttl); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set blocking status", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
|
|
// delete comments for permanently blocked user.
|
|
if blockStatus && ttl == time.Duration(0) {
|
|
if err := a.dataService.DeleteUser(siteID, userID, store.SoftDelete); err != nil {
|
|
log.Printf("[WARN] can't delete comments for blocked user %s on site %s, %v", userID, siteID, err)
|
|
}
|
|
}
|
|
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
|
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
|
|
}
|
|
|
|
// GET /blocked?site=siteID - list blocked users
|
|
func (a *admin) blockedUsersCtrl(w http.ResponseWriter, r *http.Request) {
|
|
siteID := r.URL.Query().Get("site")
|
|
users, err := a.dataService.BlockedUsers(siteID)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get blocked users", rest.ErrSiteNotFound)
|
|
return
|
|
}
|
|
R.RenderJSON(w, users)
|
|
}
|
|
|
|
// PUT /readonly?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
|
func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
|
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
|
roStatus := r.URL.Query().Get("ro") == "1"
|
|
|
|
isRoByAge := func(info store.PostInfo) bool {
|
|
return a.readOnlyAge > 0 && !info.FirstTS.IsZero() &&
|
|
info.FirstTS.AddDate(0, 0, a.readOnlyAge).Before(time.Now())
|
|
}
|
|
|
|
// don't allow to reset ro for posts turned to ro by ReadOnlyAge
|
|
if !roStatus {
|
|
if info, e := a.dataService.Info(locator, a.readOnlyAge); e == nil && isRoByAge(info) {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"),
|
|
"read-only due the age", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := a.dataService.SetReadOnly(locator, roStatus); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set readonly status", rest.ErrPostNotFound)
|
|
return
|
|
}
|
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, locator.SiteID))
|
|
R.RenderJSON(w, R.JSON{"locator": locator, "read-only": roStatus})
|
|
}
|
|
|
|
// PUT /title/{id}?site=siteID&url=post-url - set comment PostTitle to page's title
|
|
func (a *admin) setTitleCtrl(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")}
|
|
|
|
c, err := a.dataService.SetTitle(locator, id)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't set title", rest.ErrInternal)
|
|
return
|
|
}
|
|
log.Printf("[INFO] set comment's title %s to %q", id, c.PostTitle)
|
|
|
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, lastCommentsScope))
|
|
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
|
|
}
|
|
|
|
// PUT /verify?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
|
func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "userid")
|
|
siteID := r.URL.Query().Get("site")
|
|
verifyStatus := r.URL.Query().Get("verified") == "1"
|
|
|
|
if err := a.dataService.SetVerified(siteID, userID, verifyStatus); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set verify status", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
a.cache.Flush(cache.Flusher(siteID).Scopes(siteID, userID))
|
|
R.RenderJSON(w, R.JSON{"user": userID, "verified": verifyStatus})
|
|
}
|
|
|
|
// PUT /pin/{id}?site=siteID&url=post-url&pin=1
|
|
// mark/unmark comment as a special
|
|
func (a *admin) setPinCtrl(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")}
|
|
pinStatus := r.URL.Query().Get("pin") == "1"
|
|
|
|
if err := a.dataService.SetPin(locator, commentID, pinStatus); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set pin status", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL))
|
|
R.RenderJSON(w, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
|
|
}
|