737 lines
25 KiB
Go
737 lines
25 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/rand"
|
|
"crypto/sha1" //nolint:gosec //not used for security
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/render"
|
|
"github.com/go-pkgz/auth"
|
|
"github.com/go-pkgz/auth/token"
|
|
cache "github.com/go-pkgz/lcw"
|
|
log "github.com/go-pkgz/lgr"
|
|
R "github.com/go-pkgz/rest"
|
|
"github.com/golang-jwt/jwt"
|
|
"github.com/hashicorp/go-multierror"
|
|
|
|
"github.com/umputun/remark42/backend/app/notify"
|
|
"github.com/umputun/remark42/backend/app/rest"
|
|
"github.com/umputun/remark42/backend/app/store"
|
|
"github.com/umputun/remark42/backend/app/store/engine"
|
|
"github.com/umputun/remark42/backend/app/store/image"
|
|
"github.com/umputun/remark42/backend/app/store/service"
|
|
"github.com/umputun/remark42/backend/app/templates"
|
|
)
|
|
|
|
type private struct {
|
|
dataService privStore
|
|
cache LoadingCache
|
|
readOnlyAge int
|
|
commentFormatter *store.CommentFormatter
|
|
imageService *image.Service
|
|
notifyService *notify.Service
|
|
authenticator *auth.Service
|
|
telegramService telegramService
|
|
remarkURL string
|
|
anonVote bool
|
|
templates templates.FileReader
|
|
}
|
|
|
|
// telegramService is a subset of Telegram service used for setting up user telegram notifications
|
|
type telegramService interface {
|
|
AddToken(token, user, site string, expires time.Time)
|
|
CheckToken(token, userID string) (telegram, site string, err error)
|
|
GetBotUsername() string
|
|
}
|
|
|
|
type privStore interface {
|
|
Create(comment store.Comment) (commentID string, err error)
|
|
EditComment(locator store.Locator, commentID string, req service.EditRequest) (comment store.Comment, err error)
|
|
Vote(req service.VoteReq) (comment store.Comment, err error)
|
|
Get(locator store.Locator, commentID string, user store.User) (store.Comment, error)
|
|
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
|
|
GetUserEmail(siteID string, userID string) (string, error)
|
|
SetUserEmail(siteID string, userID string, value string) (string, error)
|
|
GetUserTelegram(siteID string, userID string) (string, error)
|
|
SetUserTelegram(siteID string, userID string, value string) (string, error)
|
|
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
|
|
ValidateComment(c *store.Comment) error
|
|
IsVerified(siteID string, userID string) bool
|
|
IsReadOnly(locator store.Locator) bool
|
|
IsBlocked(siteID string, userID string) bool
|
|
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
|
|
}
|
|
|
|
// POST /preview, body is a comment, returns rendered html
|
|
func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
|
|
comment := store.Comment{}
|
|
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
|
return
|
|
}
|
|
|
|
comment.User = user
|
|
comment.Orig = comment.Text
|
|
if err := s.dataService.ValidateComment(&comment); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
|
return
|
|
}
|
|
|
|
comment = s.commentFormatter.Format(comment)
|
|
comment.Sanitize()
|
|
|
|
// check if images are valid
|
|
for _, id := range s.imageService.ExtractPictures(comment.Text) {
|
|
err := s.imageService.ResetCleanupTimer(id)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't renew staged picture cleanup timer", rest.ErrImgNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
render.HTML(w, r, comment.Text)
|
|
}
|
|
|
|
// POST /comment - adds comment, resets all immutable fields
|
|
func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|
comment := store.Comment{}
|
|
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
|
return
|
|
}
|
|
|
|
user := rest.MustGetUserInfo(r)
|
|
if user.ID != "admin" && user.SiteID != comment.Locator.SiteID {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden,
|
|
fmt.Errorf("site mismatch, %q not allowed to post to %s", user.SiteID, comment.Locator.SiteID), "invalid site",
|
|
rest.ErrCommentValidation)
|
|
return
|
|
}
|
|
|
|
comment.PrepareUntrusted() // clean all fields user not supposed to set
|
|
comment.User = user
|
|
comment.User.IP = strings.Split(r.RemoteAddr, ":")[0]
|
|
|
|
comment.Orig = comment.Text // original comment text, prior to md render
|
|
if err := s.dataService.ValidateComment(&comment); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
|
return
|
|
}
|
|
comment = s.commentFormatter.Format(comment)
|
|
|
|
// check if images are valid
|
|
for _, id := range s.imageService.ExtractPictures(comment.Text) {
|
|
_, err := s.imageService.Load(id)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
// check if user blocked
|
|
if s.dataService.IsBlocked(comment.Locator.SiteID, comment.User.ID) {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"), "user blocked", rest.ErrUserBlocked)
|
|
return
|
|
}
|
|
|
|
if s.isReadOnly(comment.Locator) {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"), "old post, read-only", rest.ErrReadOnly)
|
|
return
|
|
}
|
|
|
|
id, err := s.dataService.Create(comment)
|
|
if err == service.ErrRestrictedWordsFound {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords)
|
|
return
|
|
}
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save comment", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
// dataService modifies comment
|
|
finalComment, err := s.dataService.Get(comment.Locator, id, rest.GetUserOrEmpty(r))
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't load created comment", rest.ErrInternal)
|
|
return
|
|
}
|
|
s.cache.Flush(cache.Flusher(comment.Locator.SiteID).
|
|
Scopes(comment.Locator.URL, lastCommentsScope, comment.User.ID, comment.Locator.SiteID))
|
|
|
|
if s.notifyService != nil {
|
|
s.notifyService.Submit(notify.Request{Comment: finalComment})
|
|
}
|
|
|
|
log.Printf("[DEBUG] created comment %+v", finalComment)
|
|
|
|
render.Status(r, http.StatusCreated)
|
|
render.JSON(w, r, &finalComment)
|
|
}
|
|
|
|
// PUT /comment/{id}?site=siteID&url=post-url - update comment
|
|
func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|
edit := struct {
|
|
Text string
|
|
Summary string
|
|
Delete bool
|
|
}{}
|
|
|
|
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &edit); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
|
return
|
|
}
|
|
|
|
user := rest.MustGetUserInfo(r)
|
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
|
id := chi.URLParam(r, "id")
|
|
|
|
log.Printf("[DEBUG] update comment %s", id)
|
|
|
|
var currComment store.Comment
|
|
var err error
|
|
if currComment, err = s.dataService.Get(locator, id, rest.GetUserOrEmpty(r)); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't find comment", rest.ErrCommentNotFound)
|
|
return
|
|
}
|
|
|
|
if currComment.User.ID != user.ID {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"),
|
|
"can not edit comments for other users", rest.ErrNoAccess)
|
|
return
|
|
}
|
|
|
|
editReq := service.EditRequest{
|
|
Text: s.commentFormatter.FormatText(edit.Text),
|
|
Orig: edit.Text,
|
|
Summary: edit.Summary,
|
|
Delete: edit.Delete,
|
|
Admin: user.Admin,
|
|
}
|
|
|
|
res, err := s.dataService.EditComment(locator, id, editReq)
|
|
if err == service.ErrRestrictedWordsFound {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
code := parseError(err, rest.ErrCommentRejected)
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't update comment", code)
|
|
return
|
|
}
|
|
|
|
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID))
|
|
render.JSON(w, r, res)
|
|
}
|
|
|
|
// GET /user?site=siteID - returns user info
|
|
func (s *private) userInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
if siteID := r.URL.Query().Get("site"); siteID != "" {
|
|
user.Verified = s.dataService.IsVerified(siteID, user.ID)
|
|
|
|
email, err := s.dataService.GetUserEmail(siteID, user.ID)
|
|
if err != nil {
|
|
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
|
}
|
|
if len(email) > 0 {
|
|
user.EmailSubscription = true
|
|
}
|
|
}
|
|
|
|
render.JSON(w, r, user)
|
|
}
|
|
|
|
// PUT /vote/{id}?site=siteID&url=post-url&vote=1 - vote for/against comment
|
|
func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
if !s.anonVote && strings.HasPrefix(user.ID, "anonymous_") {
|
|
http.Error(w, "Access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
|
id := chi.URLParam(r, "id")
|
|
log.Printf("[DEBUG] vote for comment %s", id)
|
|
|
|
vote := r.URL.Query().Get("vote") == "1"
|
|
|
|
if s.isReadOnly(locator) {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"), "old post, read-only", rest.ErrReadOnly)
|
|
return
|
|
}
|
|
|
|
// check if user blocked
|
|
if s.dataService.IsBlocked(locator.SiteID, user.ID) {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"), "user blocked", rest.ErrUserBlocked)
|
|
return
|
|
}
|
|
|
|
req := service.VoteReq{
|
|
Locator: locator,
|
|
CommentID: id,
|
|
UserID: user.ID,
|
|
UserIP: strings.Split(r.RemoteAddr, ":")[0],
|
|
Val: vote,
|
|
}
|
|
comment, err := s.dataService.Vote(req)
|
|
if err != nil {
|
|
code := parseError(err, rest.ErrVoteRejected)
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't vote for comment", code)
|
|
return
|
|
}
|
|
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID))
|
|
render.JSON(w, r, R.JSON{"id": comment.ID, "score": comment.Score})
|
|
}
|
|
|
|
// getEmailCtrl gets email address for authenticated user.
|
|
// GET /email?site=siteID
|
|
func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
siteID := r.URL.Query().Get("site")
|
|
address, err := s.dataService.GetUserEmail(siteID, user.ID)
|
|
if err != nil {
|
|
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
|
}
|
|
|
|
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
|
}
|
|
|
|
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
|
|
// GET /email/subscribe?site=siteID&address=someone@example.com
|
|
//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON
|
|
func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
address := r.URL.Query().Get("address")
|
|
siteID := r.URL.Query().Get("site")
|
|
if address == "" {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest,
|
|
fmt.Errorf("missing parameter"), "address parameter is required", rest.ErrInternal)
|
|
return
|
|
}
|
|
existingAddress, err := s.dataService.GetUserEmail(siteID, user.ID)
|
|
if err != nil {
|
|
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
|
}
|
|
if address == existingAddress {
|
|
rest.SendErrorJSON(w, r, http.StatusConflict,
|
|
fmt.Errorf("already verified"), "email address is already verified for this user", rest.ErrInternal)
|
|
return
|
|
}
|
|
claims := token.Claims{
|
|
Handshake: &token.Handshake{ID: user.ID + "::" + address},
|
|
StandardClaims: jwt.StandardClaims{
|
|
Audience: r.URL.Query().Get("site"),
|
|
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
|
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
|
Issuer: "remark42",
|
|
},
|
|
}
|
|
|
|
tkn, err := s.authenticator.TokenService().Token(claims)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to make verification token", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
s.notifyService.SubmitVerification(
|
|
notify.VerificationRequest{
|
|
SiteID: siteID,
|
|
User: user.Name,
|
|
Email: address,
|
|
Token: tkn,
|
|
},
|
|
)
|
|
|
|
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
|
}
|
|
|
|
// telegramSubscribeCtrl generates and verifies telegram notification request
|
|
// GET /telegram/subscribe?site=siteID<&tkn=token>
|
|
func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
|
|
if s.telegramService == nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError,
|
|
fmt.Errorf("not enabled"), "telegram notifications are not enabled", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
|
|
queryToken := r.URL.Query().Get("tkn")
|
|
if queryToken == "" {
|
|
// GET /telegram/subscribe?site=siteID (No token supplied)
|
|
siteID := r.URL.Query().Get("site")
|
|
if siteID == "" {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("missing parameter"), "site parameter is required", rest.ErrInternal)
|
|
return
|
|
}
|
|
// we don't care as much if we can't retrieve the current value of that field for the user, so ignore the error
|
|
if existingAddress, _ := s.dataService.GetUserTelegram(siteID, user.ID); existingAddress != "" {
|
|
rest.SendErrorJSON(w, r, http.StatusConflict,
|
|
fmt.Errorf("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected)
|
|
return
|
|
}
|
|
// Generate and send token
|
|
tkn, err := randToken()
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal)
|
|
return
|
|
}
|
|
expires := time.Now().Add(10 * time.Minute)
|
|
|
|
s.telegramService.AddToken(tkn, user.ID, siteID, expires)
|
|
|
|
render.JSON(w, r, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()})
|
|
|
|
return
|
|
}
|
|
|
|
// GET /telegram/subscribe?tkn=token (verify token)
|
|
var address, siteID string
|
|
address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't set telegram for user", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
log.Printf("[DEBUG] set telegram notifications for user %s", user.ID)
|
|
|
|
val, err := s.dataService.SetUserTelegram(siteID, user.ID, address)
|
|
if err != nil {
|
|
code := parseError(err, rest.ErrInternal)
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set telegram for user", code)
|
|
return
|
|
}
|
|
|
|
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
|
}
|
|
|
|
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
|
|
// PUT /email/confirm?site=siteID&tkn=jwt
|
|
func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
|
tkn := r.URL.Query().Get("tkn")
|
|
if tkn == "" {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("missing parameter"), "token parameter is required", rest.ErrInternal)
|
|
return
|
|
}
|
|
user := rest.MustGetUserInfo(r)
|
|
siteID := r.URL.Query().Get("site")
|
|
|
|
confClaims, err := s.authenticator.TokenService().Parse(tkn)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
if s.authenticator.TokenService().IsExpired(confClaims) {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("expired"), "failed to verify confirmation token", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
// Handshake.ID is user.ID + "::" + address
|
|
elems := strings.Split(confClaims.Handshake.ID, "::")
|
|
if len(elems) != 2 || elems[0] != user.ID {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
|
return
|
|
}
|
|
address := elems[1]
|
|
|
|
log.Printf("[DEBUG] set email for user %s", user.ID)
|
|
|
|
val, err := s.dataService.SetUserEmail(siteID, user.ID, address)
|
|
if err != nil {
|
|
code := parseError(err, rest.ErrInternal)
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code)
|
|
return
|
|
}
|
|
|
|
// update User.Email from the token
|
|
claims, _, err := s.authenticator.TokenService().Get(r)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
|
return
|
|
}
|
|
claims.User.Email = address
|
|
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
|
return
|
|
}
|
|
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
|
}
|
|
|
|
// POST/GET /email/unsubscribe.html?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications
|
|
func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
|
tkn := r.URL.Query().Get("tkn")
|
|
if tkn == "" {
|
|
rest.SendErrorHTML(w, r, http.StatusBadRequest,
|
|
fmt.Errorf("missing parameter"), "token parameter is required", rest.ErrInternal, s.templates)
|
|
return
|
|
}
|
|
siteID := r.URL.Query().Get("site")
|
|
|
|
confClaims, err := s.authenticator.TokenService().Parse(tkn)
|
|
if err != nil {
|
|
rest.SendErrorHTML(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal, s.templates)
|
|
return
|
|
}
|
|
|
|
if s.authenticator.TokenService().IsExpired(confClaims) {
|
|
rest.SendErrorHTML(w, r, http.StatusForbidden,
|
|
fmt.Errorf("expired"), "failed to verify confirmation token", rest.ErrInternal, s.templates)
|
|
return
|
|
}
|
|
|
|
// Handshake.ID is user.ID + "::" + address
|
|
elems := strings.Split(confClaims.Handshake.ID, "::")
|
|
if len(elems) != 2 {
|
|
rest.SendErrorHTML(w, r, http.StatusBadRequest,
|
|
fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal, s.templates)
|
|
return
|
|
}
|
|
userID := elems[0]
|
|
address := elems[1]
|
|
|
|
existingAddress, err := s.dataService.GetUserEmail(siteID, userID)
|
|
if err != nil {
|
|
// we don't care as much if we can't retrieve the current value of that field for the user,
|
|
// as it's only used to check if we're trying to set to the same value it's already set to
|
|
log.Printf("[WARN] can't read email for %s, %v", userID, err)
|
|
}
|
|
if existingAddress == "" {
|
|
rest.SendErrorHTML(w, r, http.StatusConflict,
|
|
fmt.Errorf("user is not subscribed"), "user does not have active email subscription", rest.ErrInternal, s.templates)
|
|
return
|
|
}
|
|
if address != existingAddress {
|
|
rest.SendErrorHTML(w, r, http.StatusBadRequest,
|
|
fmt.Errorf("wrong email unsubscription"), "email address in request does not match known for this user",
|
|
rest.ErrInternal, s.templates)
|
|
return
|
|
}
|
|
|
|
log.Printf("[DEBUG] unsubscribe user %s", userID)
|
|
|
|
if err = s.dataService.DeleteUserDetail(siteID, userID, engine.UserEmail); err != nil {
|
|
code := parseError(err, rest.ErrInternal)
|
|
rest.SendErrorHTML(w, r, http.StatusBadRequest, err, "can't delete email for user", code, s.templates)
|
|
return
|
|
}
|
|
// clean User.Email from the token, if user has the token
|
|
claims, _, err := s.authenticator.TokenService().Get(r)
|
|
if err != nil {
|
|
log.Printf("[DEBUG] unsubscribed user doesn't have valid JWT token to update %s, %v", userID, err)
|
|
}
|
|
if claims.User != nil && claims.User.Email != "" {
|
|
claims.User.Email = ""
|
|
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
|
|
rest.SendErrorHTML(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal, s.templates)
|
|
return
|
|
}
|
|
}
|
|
|
|
// MustExecute behaves like template.Execute, but panics if an error occurs.
|
|
MustExecute := func(tmpl *template.Template, wr io.Writer, data interface{}) {
|
|
if err := tmpl.Execute(wr, data); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
MustRead := func(path string) string {
|
|
file, err := s.templates.ReadFile(path)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(file)
|
|
}
|
|
tmplstr := MustRead("unsubscribe.html.tmpl")
|
|
tmpl := template.Must(template.New("unsubscribe").Parse(tmplstr))
|
|
msg := bytes.Buffer{}
|
|
MustExecute(tmpl, &msg, nil)
|
|
render.HTML(w, r, msg.String())
|
|
}
|
|
|
|
// DELETE /email?site=siteID - removes user's email
|
|
func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
siteID := r.URL.Query().Get("site")
|
|
log.Printf("[DEBUG] remove email for user %s", user.ID)
|
|
|
|
if err := s.dataService.DeleteUserDetail(siteID, user.ID, engine.UserEmail); err != nil {
|
|
code := parseError(err, rest.ErrInternal)
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
|
|
return
|
|
}
|
|
// clean User.Email from the token
|
|
claims, _, err := s.authenticator.TokenService().Get(r)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
|
return
|
|
}
|
|
if claims.User.Email != "" {
|
|
claims.User.Email = ""
|
|
if _, err = s.authenticator.TokenService().Set(w, claims); err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
|
return
|
|
}
|
|
}
|
|
render.JSON(w, r, R.JSON{"deleted": true})
|
|
}
|
|
|
|
// DELETE /telegram?site=siteID - removes user's telegram
|
|
func (s *private) deleteTelegramCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
siteID := r.URL.Query().Get("site")
|
|
log.Printf("[DEBUG] remove telegram for user %s", user.ID)
|
|
|
|
if err := s.dataService.DeleteUserDetail(siteID, user.ID, engine.UserTelegram); err != nil {
|
|
code := parseError(err, rest.ErrInternal)
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete telegram for user", code)
|
|
return
|
|
}
|
|
render.JSON(w, r, R.JSON{"deleted": true})
|
|
}
|
|
|
|
// GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments
|
|
func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
|
|
siteID := r.URL.Query().Get("site")
|
|
user := rest.MustGetUserInfo(r)
|
|
userB, err := json.Marshal(&user)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't marshal user info", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
exportFile := fmt.Sprintf("%s-%s-%s.json.gz", siteID, user.ID, time.Now().Format("20060102"))
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Disposition", "attachment;filename="+exportFile)
|
|
gzWriter := gzip.NewWriter(w)
|
|
defer func() {
|
|
if e := gzWriter.Close(); e != nil {
|
|
log.Printf("[WARN] can't close gzip writer, %s", e)
|
|
}
|
|
}()
|
|
|
|
write := func(val []byte) error {
|
|
_, e := gzWriter.Write(val)
|
|
return e
|
|
}
|
|
|
|
var merr error
|
|
merr = multierror.Append(merr, write([]byte(`{"info": `))) // send user prefix
|
|
merr = multierror.Append(merr, write(userB)) // send user info
|
|
merr = multierror.Append(merr, write([]byte(`, "comments":`))) // send comments prefix
|
|
|
|
// get comments in 100 in each paginated request
|
|
for i := 0; i < 100; i++ {
|
|
comments, errUser := s.dataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r))
|
|
if errUser != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal)
|
|
return
|
|
}
|
|
b, errUser := json.Marshal(comments)
|
|
if errUser != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't marshal user comments", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
merr = multierror.Append(merr, write(b))
|
|
if len(comments) != 100 {
|
|
break
|
|
}
|
|
}
|
|
|
|
merr = multierror.Append(merr, write([]byte(`}`)))
|
|
if merr.(*multierror.Error).ErrorOrNil() != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, merr, "can't write user info", rest.ErrInternal)
|
|
return
|
|
}
|
|
}
|
|
|
|
// POST /deleteme?site_id=site - requesting delete of all user info
|
|
// makes jwt with user info and sends it back as a part of json response
|
|
func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
siteID := r.URL.Query().Get("site")
|
|
|
|
claims := token.Claims{
|
|
StandardClaims: jwt.StandardClaims{
|
|
Audience: siteID,
|
|
Issuer: "remark42",
|
|
ExpiresAt: time.Now().AddDate(0, 3, 0).Unix(),
|
|
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
|
},
|
|
User: &token.User{
|
|
ID: user.ID,
|
|
Name: user.Name,
|
|
Attributes: map[string]interface{}{
|
|
"delete_me": true, // prevents this token from being used for login
|
|
},
|
|
},
|
|
}
|
|
|
|
tokenStr, err := s.authenticator.TokenService().Token(claims)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't make token", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.remarkURL, tokenStr)
|
|
render.JSON(w, r, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link})
|
|
}
|
|
|
|
// POST /image - save image with form request
|
|
func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
|
|
user := rest.MustGetUserInfo(r)
|
|
|
|
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { // 5M max memory, if bigger will make a file
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
|
return
|
|
}
|
|
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get image file from the request", rest.ErrInternal)
|
|
return
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
id, err := s.imageService.Save(user.ID, file)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't save image", rest.ErrInternal)
|
|
return
|
|
}
|
|
|
|
render.JSON(w, r, R.JSON{"id": id})
|
|
}
|
|
|
|
func (s *private) isReadOnly(locator store.Locator) bool {
|
|
if s.readOnlyAge > 0 {
|
|
// check RO by age
|
|
if info, e := s.dataService.Info(locator, s.readOnlyAge); e == nil && info.ReadOnly {
|
|
return true
|
|
}
|
|
}
|
|
return s.dataService.IsReadOnly(locator) // ro manually
|
|
}
|
|
|
|
func randToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("can't get random: %w", err)
|
|
}
|
|
s := sha1.New() //nolint:gosec // not used for security
|
|
if _, err := s.Write(b); err != nil {
|
|
return "", fmt.Errorf("can't write randoms to sha1: %w", err)
|
|
}
|
|
return fmt.Sprintf("%x", s.Sum(nil)), nil
|
|
}
|