Bumps go-pkgz/auth to v2.1.5, which adds avatar.ErrNotFound. deleteMeRequestCtrl's avatar removal was best-effort (log and continue on any error) because before the sentinel there was no portable way to tell an already-removed avatar from a genuine failure. It now tolerates only errors.Is(err, avatar.ErrNotFound) - keeping the repeated-request idempotency - and surfaces any other store failure as 500.
265 lines
11 KiB
Go
265 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-pkgz/auth/v2"
|
|
"github.com/go-pkgz/auth/v2/avatar"
|
|
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 := r.PathValue("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 := r.PathValue("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 := r.PathValue("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 {
|
|
if avatarID := avatarIDFromPicture(claims.User.Picture); avatarID != "" {
|
|
// an already-removed avatar is fine (a repeated request stays idempotent), but a genuine
|
|
// store failure is surfaced now that avatar.ErrNotFound lets us tell the two apart
|
|
if err = a.authenticator.AvatarProxy().Store.Remove(avatarID); err != nil && !errors.Is(err, avatar.ErrNotFound) {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete user's avatar", rest.ErrInternal)
|
|
return
|
|
}
|
|
} else {
|
|
log.Printf("[WARN] unexpected avatar picture %q for user %s on site %s, skipping removal", claims.User.Picture, claims.User.ID, audience)
|
|
}
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
// avatarIDFromPicture returns the avatar-store object id for a user picture, or "" if the picture
|
|
// does not resolve to a well-formed id (the store names its objects "<hash>.image"). Guarding on the
|
|
// id shape keeps a malformed picture, e.g. a path sentinel, from making a filesystem-backed store
|
|
// target an unexpected path.
|
|
func avatarIDFromPicture(picture string) string {
|
|
if id := path.Base(picture); strings.HasSuffix(id, ".image") {
|
|
return id
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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 := r.PathValue("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 := r.PathValue("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/{userid}?site=siteID&verified=1 - set or reset verified status for the user
|
|
func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
|
userID := r.PathValue("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 := r.PathValue("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})
|
|
}
|