Permalink
Cannot retrieve contributors at this time
717 lines (625 sloc)
23.3 KB
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
remark42/backend/app/rest/api/rest.go
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package api | |
import ( | |
"bytes" | |
"context" | |
"encoding/json" | |
"fmt" | |
"net/http" | |
"net/mail" | |
"regexp" | |
"strings" | |
"sync" | |
"time" | |
"github.com/didip/tollbooth/v7" | |
"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" | |
) | |
// 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("/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, | |
} | |
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, | |
} | |
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, site or email are suspicious | |
func validEmaiAuth() func(http.Handler) http.Handler { | |
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation | |
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_]{1,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 | |
} | |
} | |
if a := r.URL.Query().Get("address"); a != "" { | |
if _, err := mail.ParseAddress(a); err != nil { | |
http.Error(w, "Access denied", http.StatusForbidden) | |
return | |
} | |
} | |
if s := r.URL.Query().Get("site"); s != "" { | |
if !reSite.MatchString(s) { | |
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 | |
} |