Files
remark42/backend/app/rest/api/rest.go
T

706 lines
23 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/didip/tollbooth/v6"
"github.com/didip/tollbooth_chi"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/render"
"github.com/go-pkgz/auth"
"github.com/go-pkgz/lcw"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/go-pkgz/rest/logger"
"github.com/rakyll/statik/fs"
"github.com/umputun/remark42/backend/app/notify"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/rest/proxy"
"github.com/umputun/remark42/backend/app/store"
"github.com/umputun/remark42/backend/app/store/image"
"github.com/umputun/remark42/backend/app/store/service"
"github.com/umputun/remark42/backend/app/templates"
)
// Rest is a rest access server
type Rest struct {
Version string
DataService *service.DataStore
Authenticator *auth.Service
Cache LoadingCache
ImageProxy *proxy.Image
CommentFormatter *store.CommentFormatter
Migrator *Migrator
NotifyService *notify.Service
TelegramService telegramService
ImageService *image.Service
AnonVote bool
WebRoot string
RemarkURL string
ReadOnlyAge int
SharedSecret string
ScoreThresholds struct {
Low int
Critical int
}
UpdateLimiter float64
EmailNotifications bool
TelegramBotUsername string
EmojiEnabled bool
SimpleView bool
ProxyCORS bool
SendJWTHeader bool
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
SubscribersOnly bool
DisableSignature bool // prevent signature from being added to headers
SSLConfig SSLConfig
httpsServer *http.Server
httpServer *http.Server
lock sync.Mutex
pubRest public
privRest private
adminRest admin
rssRest rss
}
// LoadingCache defines interface for caching
type LoadingCache interface {
Get(key lcw.Key, fn func() ([]byte, error)) (data []byte, err error) // load from cache if found or put to cache and return
Flush(req lcw.FlusherRequest) // evict matched records
Close() error
}
const hardBodyLimit = 1024 * 64 // limit size of body
const lastCommentsScope = "last"
type commentsWithInfo struct {
Comments []store.Comment `json:"comments"`
Info store.PostInfo `json:"info,omitempty"`
}
// Run the lister and request's router, activate rest server
func (s *Rest) Run(address string, port int) {
if address == "*" {
address = ""
}
switch s.SSLConfig.SSLMode {
case None:
log.Printf("[INFO] activate http rest server on %s:%d", address, port)
s.lock.Lock()
s.httpServer = s.makeHTTPServer(address, port, s.routes())
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.lock.Unlock()
err := s.httpServer.ListenAndServe()
log.Printf("[WARN] http server terminated, %s", err)
case Static:
log.Printf("[INFO] activate https server in 'static' mode on %s:%d", address, s.SSLConfig.Port)
s.lock.Lock()
s.httpsServer = s.makeHTTPSServer(address, s.SSLConfig.Port, s.routes())
s.httpsServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.httpServer = s.makeHTTPServer(address, port, s.httpToHTTPSRouter())
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.lock.Unlock()
go func() {
log.Printf("[INFO] activate http redirect server on %s:%d", address, port)
err := s.httpServer.ListenAndServe()
log.Printf("[WARN] http redirect server terminated, %s", err)
}()
err := s.httpsServer.ListenAndServeTLS(s.SSLConfig.Cert, s.SSLConfig.Key)
log.Printf("[WARN] https server terminated, %s", err)
case Auto:
log.Printf("[INFO] activate https server in 'auto' mode on %s:%d", address, s.SSLConfig.Port)
m := s.makeAutocertManager()
s.lock.Lock()
s.httpsServer = s.makeHTTPSAutocertServer(address, s.SSLConfig.Port, s.routes(), m)
s.httpsServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.httpServer = s.makeHTTPServer(address, port, s.httpChallengeRouter(m))
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
s.lock.Unlock()
go func() {
log.Printf("[INFO] activate http challenge server on port %d", port)
err := s.httpServer.ListenAndServe()
log.Printf("[WARN] http challenge server terminated, %s", err)
}()
err := s.httpsServer.ListenAndServeTLS("", "")
log.Printf("[WARN] https server terminated, %s", err)
}
}
// Shutdown rest http server
func (s *Rest) Shutdown() {
log.Print("[WARN] shutdown rest server")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
s.lock.Lock()
if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("[DEBUG] http shutdown error, %s", err)
}
log.Print("[DEBUG] shutdown http server completed")
}
if s.httpsServer != nil {
log.Print("[WARN] shutdown https server")
if err := s.httpsServer.Shutdown(ctx); err != nil {
log.Printf("[DEBUG] https shutdown error, %s", err)
}
log.Print("[DEBUG] shutdown https server completed")
}
s.lock.Unlock()
}
func (s *Rest) makeHTTPServer(address string, port int, router http.Handler) *http.Server {
return &http.Server{
Addr: fmt.Sprintf("%s:%d", address, port),
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
// WriteTimeout: 120 * time.Second, // TODO: such a long timeout needed for blocking export (backup) request
IdleTimeout: 30 * time.Second,
}
}
func (s *Rest) routes() chi.Router {
router := chi.NewRouter()
router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default()))
if !s.DisableSignature {
router.Use(R.AppInfo("remark42", "umputun", s.Version))
}
router.Use(R.Ping)
s.pubRest, s.privRest, s.adminRest, s.rssRest = s.controllerGroups() // assign controllers for groups
if s.ProxyCORS {
log.Printf("[WARN] internal CORS disabled")
} else {
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"},
ExposedHeaders: []string{"Authorization"},
AllowCredentials: true,
MaxAge: 300,
})
router.Use(corsMiddleware.Handler)
}
if len(s.AllowedAncestors) > 0 {
log.Printf("[INFO] allowed from %+v only", s.AllowedAncestors)
router.Use(frameAncestors(s.AllowedAncestors))
}
ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization
logInfoWithBody := logger.New(logger.Log(log.Default()), logger.WithBody, logger.IPfn(ipFn), logger.Prefix("[INFO]")).Handler
authHandler, avatarHandler := s.Authenticator.Handlers()
router.Group(func(r chi.Router) {
r.Use(middleware.Timeout(5 * time.Second))
r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(2, nil)), middleware.NoCache)
r.Use(validEmaiAuth()) // reject suspicious email logins
r.Mount("/auth", authHandler)
})
router.Group(func(r chi.Router) {
r.Use(middleware.Timeout(5 * time.Second))
r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
r.Mount("/avatar", avatarHandler)
})
authMiddleware := s.Authenticator.Middleware()
// api routes
router.Route("/api/v1", func(rapi chi.Router) {
rapi.Group(func(rava chi.Router) {
rava.Use(middleware.Timeout(5 * time.Second))
rava.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
rava.Mount("/avatar", avatarHandler)
})
// open routes
rapi.Group(func(ropen chi.Router) {
ropen.Use(middleware.Timeout(30 * time.Second))
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody)
ropen.Get("/config", s.configCtrl)
ropen.Get("/find", s.pubRest.findCommentsCtrl)
ropen.Get("/id/{id}", s.pubRest.commentByIDCtrl)
ropen.Get("/comments", s.pubRest.findUserCommentsCtrl)
ropen.Get("/last/{limit}", s.pubRest.lastCommentsCtrl)
ropen.Get("/count", s.pubRest.countCtrl)
ropen.Post("/counts", s.pubRest.countMultiCtrl)
ropen.Get("/list", s.pubRest.listCtrl)
ropen.Get("/info", s.pubRest.infoCtrl)
ropen.Get("/img", s.ImageProxy.Handler)
ropen.Route("/rss", func(rrss chi.Router) {
rrss.Get("/post", s.rssRest.postCommentsCtrl)
rrss.Get("/site", s.rssRest.siteCommentsCtrl)
rrss.Get("/reply", s.rssRest.repliesCtrl)
})
})
// open routes, cached
rapi.Group(func(ropen chi.Router) {
ropen.Use(middleware.Timeout(30 * time.Second))
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
ropen.Use(authMiddleware.Trace, logInfoWithBody)
ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl)
ropen.Get("/qr/telegram", s.pubRest.telegramQrCtrl)
})
// protected routes, require auth
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(30 * time.Second))
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
rauth.Use(authMiddleware.Auth, matchSiteID, middleware.NoCache, logInfoWithBody)
rauth.Get("/user", s.privRest.userInfoCtrl)
rauth.Get("/userdata", s.privRest.userAllDataCtrl)
})
// admin routes, require auth and admin users only
rapi.Route("/admin", func(radmin chi.Router) {
radmin.Use(middleware.Timeout(30 * time.Second))
radmin.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
radmin.Use(middleware.NoCache, logInfoWithBody)
radmin.Delete("/comment/{id}", s.adminRest.deleteCommentCtrl)
radmin.Put("/user/{userid}", s.adminRest.setBlockCtrl)
radmin.Delete("/user/{userid}", s.adminRest.deleteUserCtrl)
radmin.Get("/user/{userid}", s.adminRest.getUserInfoCtrl)
radmin.Get("/deleteme", s.adminRest.deleteMeRequestCtrl)
radmin.Put("/verify/{userid}", s.adminRest.setVerifyCtrl)
radmin.Put("/pin/{id}", s.adminRest.setPinCtrl)
radmin.Get("/blocked", s.adminRest.blockedUsersCtrl)
radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl)
radmin.Put("/title/{id}", s.adminRest.setTitleCtrl)
// migrator
radmin.Get("/export", s.adminRest.migrator.exportCtrl)
radmin.Post("/import", s.adminRest.migrator.importCtrl)
radmin.Post("/import/form", s.adminRest.migrator.importFormCtrl)
radmin.Post("/remap", s.adminRest.migrator.remapCtrl)
radmin.Get("/wait", s.adminRest.migrator.waitCtrl)
})
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(10 * time.Second))
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
rauth.Use(middleware.NoCache, logInfoWithBody)
rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl)
rauth.Post("/preview", s.privRest.previewCommentCtrl)
rauth.Post("/comment", s.privRest.createCommentCtrl)
rauth.Put("/vote/{id}", s.privRest.voteCtrl)
rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl)
rauth.With(rejectAnonUser).Get("/email", s.privRest.getEmailCtrl)
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
rauth.With(rejectAnonUser).Get("/telegram/subscribe", s.privRest.telegramSubscribeCtrl)
rauth.With(rejectAnonUser).Delete("/telegram", s.privRest.deleteTelegramCtrl)
})
// protected routes, anonymous rejected
rapi.Group(func(rauth chi.Router) {
rauth.Use(middleware.Timeout(10 * time.Second))
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
rauth.Post("/picture", s.privRest.savePictureCtrl)
})
})
// open routes on root level
router.Group(func(rroot chi.Router) {
rroot.Use(middleware.Timeout(10 * time.Second))
rroot.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil)))
rroot.Get("/index.html", s.pubRest.getStartedCtrl)
rroot.Get("/robots.txt", s.pubRest.robotsCtrl)
rroot.Get("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
rroot.Post("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
})
// file server for static content from /web
addFileServer(router, "/web", http.Dir(s.WebRoot), s.Version)
return router
}
func (s *Rest) controllerGroups() (public, private, admin, rss) {
pubGrp := public{
dataService: s.DataService,
cache: s.Cache,
imageService: s.ImageService,
commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge,
webRoot: s.WebRoot,
}
privGrp := private{
dataService: s.DataService,
cache: s.Cache,
imageService: s.ImageService,
commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge,
authenticator: s.Authenticator,
notifyService: s.NotifyService,
telegramService: s.TelegramService,
remarkURL: s.RemarkURL,
anonVote: s.AnonVote,
templates: templates.NewFS(),
}
admGrp := admin{
dataService: s.DataService,
migrator: s.Migrator,
cache: s.Cache,
authenticator: s.Authenticator,
readOnlyAge: s.ReadOnlyAge,
}
rssGrp := rss{
dataService: s.DataService,
cache: s.Cache,
}
return pubGrp, privGrp, admGrp, rssGrp
}
// updateLimiter returns UpdateLimiter if set, or 10 if not
func (s *Rest) updateLimiter() float64 {
lmt := 10.0
if s.UpdateLimiter > 0 {
lmt = s.UpdateLimiter
}
return lmt
}
// GET /config?site=siteID - returns configuration
func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
admins, _ := s.DataService.AdminStore.Admins(siteID)
emails, _ := s.DataService.AdminStore.Email(siteID)
cnf := struct {
Version string `json:"version"`
EditDuration int `json:"edit_duration"`
AdminEdit bool `json:"admin_edit"`
MaxCommentSize int `json:"max_comment_size"`
Admins []string `json:"admins"`
AdminEmail string `json:"admin_email"`
Auth []string `json:"auth_providers"`
AnonVote bool `json:"anon_vote"`
LowScore int `json:"low_score"`
CriticalScore int `json:"critical_score"`
PositiveScore bool `json:"positive_score"`
ReadOnlyAge int `json:"readonly_age"`
MaxImageSize int `json:"max_image_size"`
EmailNotifications bool `json:"email_notifications"`
TelegramBotUsername string `json:"telegram_bot_username"`
EmojiEnabled bool `json:"emoji_enabled"`
SimpleView bool `json:"simple_view"`
SendJWTHeader bool `json:"send_jwt_header"`
SubscribersOnly bool `json:"subscribers_only"`
}{
Version: s.Version,
EditDuration: int(s.DataService.EditDuration.Seconds()),
AdminEdit: s.DataService.AdminEdits,
MaxCommentSize: s.DataService.MaxCommentSize,
Admins: admins,
AdminEmail: emails,
LowScore: s.ScoreThresholds.Low,
CriticalScore: s.ScoreThresholds.Critical,
PositiveScore: s.DataService.PositiveScore,
ReadOnlyAge: s.ReadOnlyAge,
MaxImageSize: s.ImageService.MaxSize,
EmailNotifications: s.EmailNotifications,
TelegramBotUsername: s.TelegramBotUsername,
EmojiEnabled: s.EmojiEnabled,
AnonVote: s.AnonVote,
SimpleView: s.SimpleView,
SendJWTHeader: s.SendJWTHeader,
SubscribersOnly: s.SubscribersOnly,
}
cnf.Auth = []string{}
for _, ap := range s.Authenticator.Providers() {
cnf.Auth = append(cnf.Auth, ap.Name())
}
if cnf.Admins == nil { // prevent json serialization to nil
cnf.Admins = []string{}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, cnf)
}
// serves static files from /web or embedded by statik
func addFileServer(r chi.Router, path string, root http.FileSystem, version string) {
var webFS http.Handler
statikFS, err := fs.New()
if err != nil {
log.Printf("[DEBUG] no embedded assets loaded, %s", err)
log.Printf("[INFO] run file server for %s, path %s", root, path)
webFS = http.FileServer(root)
} else {
log.Printf("[INFO] run file server for %s, embedded", root)
webFS = http.FileServer(statikFS)
}
origPath := path
webFS = http.StripPrefix(path, webFS)
if path != "/" && path[len(path)-1] != '/' {
r.Get(path, http.RedirectHandler(path+"/", http.StatusMovedPermanently).ServeHTTP)
path += "/"
}
path += "*"
r.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(20, nil)),
middleware.Timeout(10*time.Second),
cacheControl(time.Hour, version),
).Get(path, func(w http.ResponseWriter, r *http.Request) {
// don't show dirs, just serve files
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != (origPath+"/") {
http.NotFound(w, r)
return
}
webFS.ServeHTTP(w, r)
})
}
func encodeJSONWithHTML(v interface{}) ([]byte, error) {
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, fmt.Errorf("json encoding failed: %w", err)
}
return buf.Bytes(), nil
}
func filterComments(comments []store.Comment, fn func(c store.Comment) bool) []store.Comment {
filtered := []store.Comment{}
for _, c := range comments {
if fn(c) {
filtered = append(filtered, c)
}
}
return filtered
}
// URLKey gets url from request to use it as cache key
// admins will have different keys in order to prevent leak of admin-only data to regular users
func URLKey(r *http.Request) string {
adminPrefix := "admin!!"
key := strings.TrimPrefix(r.URL.String(), adminPrefix) // prevents attach with fake url to get admin view
if user, err := rest.GetUserInfo(r); err == nil && user.Admin {
key = adminPrefix + key // make separate cache key for admins
}
return key
}
// URLKeyWithUser gets url from request to use it as cache key and attaching user ID
// admins will have different keys in order to prevent leak of admin-only data to regular users
func URLKeyWithUser(r *http.Request) string {
adminPrefix := "admin!!"
key := strings.TrimPrefix(r.URL.String(), adminPrefix) // prevents attach with fake url to get admin view
if user, err := rest.GetUserInfo(r); err == nil {
if user.Admin {
key = adminPrefix + user.ID + "!!" + key // make separate cache key for admins
} else {
key = user.ID + "!!" + key // make separate cache key for authed users
}
}
return key
}
// rejectAnonUser is a middleware rejecting anonymous users
func rejectAnonUser(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if strings.HasPrefix(user.ID, "anonymous_") {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
func matchSiteID(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// skip for basic auth user
if user.Name == "admin" && user.ID == "admin" {
next.ServeHTTP(w, r)
return
}
siteID := r.URL.Query().Get("site")
if siteID != "" && user.SiteID != siteID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// cacheControl is a middleware setting cache expiration. Using url+version as etag
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
etag := func(r *http.Request, version string) string {
s := version + ":" + r.URL.String()
return store.EncodeID(s)
}
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
e := `"` + etag(r, version) + `"`
w.Header().Set("Etag", e)
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, e) {
w.WriteHeader(http.StatusNotModified)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// frameAncestors is a middleware setting Content-Security-Policy "frame-ancestors host1 host2 ..."
// prevents loading of comments widgets from any other origins. In case if the list of allowed empty, ignored.
func frameAncestors(hosts []string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if len(hosts) == 0 {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Security-Policy", "frame-ancestors "+strings.Join(hosts, " ")+";")
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// subscribersOnly is a middleware rejecting non-paid_sub users
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if enable {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !user.PaidSub {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// validEmaiAuth is a middleware for auth endpoints for email method.
// it rejects login request if user or email are suspicious
func validEmaiAuth() func(http.Handler) http.Handler {
// matches ui side validation, adding min/max limitation
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`)
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/email/login" {
// not email login, skip the check
h.ServeHTTP(w, r)
return
}
if u := r.URL.Query().Get("user"); u != "" {
if !reUser.MatchString(u) {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
func parseError(err error, defaultCode int) (code int) {
code = defaultCode
switch {
// voting errors
case strings.Contains(err.Error(), "can not vote for his own comment"):
code = rest.ErrVoteSelf
case strings.Contains(err.Error(), "already voted for"):
code = rest.ErrVoteDbl
case strings.Contains(err.Error(), "maximum number of votes exceeded for comment"):
code = rest.ErrVoteMax
case strings.Contains(err.Error(), "minimal score reached for comment"):
code = rest.ErrVoteMinScore
// edit errors
case strings.HasPrefix(err.Error(), "too late to edit"):
code = rest.ErrCommentEditExpired
case strings.HasPrefix(err.Error(), "parent comment with reply can't be edited"):
code = rest.ErrCommentEditChanged
}
return code
}