Files
neko-rooms/internal/server/manager.go
T
Miroslav Šedivý 47ee523205 Allow API versioning (#85)
* add api v3 partial support.

* fix codecs in env.

* fix default codecs.

* split files.

* add session stats for api v3.

* fix API token env.

* fix null array in json.

* add CORS.

* save api version to labels.

* irgnore api token env.

* remove duplicate api version.

* move api version to settings.
2023-10-21 21:26:02 +02:00

281 lines
7.0 KiB
Go

package server
import (
"context"
"errors"
"io"
"io/fs"
"net"
"net/http"
"os"
"path"
"path/filepath"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko-rooms/internal/config"
"github.com/m1k1o/neko-rooms/internal/types"
)
type ServerManagerCtx struct {
logger zerolog.Logger
router *chi.Mux
server *http.Server
config *config.Server
}
func New(ApiManager types.ApiManager, roomConfig *config.Room, config *config.Server, proxyHandler http.Handler) *ServerManagerCtx {
logger := log.With().Str("module", "server").Logger()
router := chi.NewRouter()
router.Use(middleware.RequestID) // Create a request ID for each request
// get real users ip
if config.Proxy {
router.Use(middleware.RealIP)
}
// add http logger
router.Use(middleware.RequestLogger(&logformatter{logger}))
router.Use(middleware.Recoverer) // Recover from panics without crashing server
// Basic CORS
if config.CORS {
// for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing
router.Use(cors.Handler(cors.Options{
AllowOriginFunc: func(r *http.Request, origin string) bool {
return true
},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
}
// mount pprof endpoint
if config.PProf {
withPProf(router)
logger.Info().Msgf("with pprof endpoint at %s", pprofPath)
}
//
// admin page
//
protected := func(next http.Handler) http.Handler {
// if proxy auth is enabled
if config.Admin.ProxyAuth != "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req, err := http.NewRequest("GET", config.Admin.ProxyAuth, nil)
if err != nil {
logger.Err(err).Msg("proxy auth request `http.NewRequest` err")
http.Error(w, "proxy auth failed", http.StatusForbidden)
return
}
//
// copy headers
//
for k, vv := range r.Header {
for _, v := range vv {
req.Header.Add(k, v)
}
}
//
// add x-forwarded headers
//
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
req.Header.Add("X-Forwarded-For", clientIP)
}
if r.Method != "" {
req.Header.Add("X-Forwarded-Method", r.Method)
} else {
req.Header.Del("X-Forwarded-Method")
}
if r.TLS != nil {
req.Header.Add("X-Forwarded-Proto", "https")
} else {
req.Header.Add("X-Forwarded-Proto", "http")
}
if r.Host != "" {
req.Header.Add("X-Forwarded-Host", r.Host)
} else {
req.Header.Del("X-Forwarded-Host")
}
if r.URL.RequestURI() != "" {
req.Header.Add("X-Forwarded-Uri", r.URL.RequestURI())
} else {
req.Header.Del("X-Forwarded-Uri")
}
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
res, err := client.Do(req)
if err != nil {
logger.Err(err).Msg("proxy auth request `client.Do` err")
http.Error(w, "proxy auth failed", http.StatusForbidden)
return
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
// copy headers
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
// copy status & body
w.WriteHeader(res.StatusCode)
_, err := io.Copy(w, res.Body)
if err != nil {
logger.Err(err).Msg("proxy auth request `io.Copy` err")
}
return
} else {
// discard body so that the connection can be reused
_, _ = io.Copy(io.Discard, res.Body)
}
next.ServeHTTP(w, r)
})
}
// if basic auth is enabled
if config.Admin.Username != "" && config.Admin.Password != "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != config.Admin.Username || pass != config.Admin.Password {
w.Header().Add("WWW-Authenticate", `Basic realm="neko-rooms admin"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
return next
}
// DEPRECATED: admin should not be served from the same path as rooms
if config.Admin.PathPrefix == "/" {
// cache static file paths
staticFiles := map[string]struct{}{}
if config.Admin.Static != "" {
filepath.Walk(config.Admin.Static,
func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
staticFiles[path.Clean(p)] = struct{}{}
return nil
})
}
// serve static files
router.Use(func(next http.Handler) http.Handler {
// if static files are disabled
if config.Admin.Static == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
filePath := path.Join(config.Admin.Static, r.URL.Path)
// check if file exists to serve it
if _, ok := staticFiles[filePath]; ok {
// serve protected assets
protected(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filePath)
})).ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
})
// serve protected API
router.With(protected).Route("/api", ApiManager.Mount)
} else {
router.With(protected).Route(config.Admin.PathPrefix+"/", func(r chi.Router) {
// serve protected API
r.Route("/api", ApiManager.Mount)
// serve static files
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
filePath := chi.URLParam(r, "*")
if filePath == "" {
filePath = "index.html"
}
filePath = filepath.Clean(filePath)
filePath = filepath.Join(config.Admin.Static, filePath)
if _, err := os.Stat(filePath); err == nil {
http.ServeFile(w, r, filePath)
} else if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
})
}
// handle all remaining paths with proxy
router.Handle("/*", proxyHandler)
return &ServerManagerCtx{
logger: logger,
router: router,
server: &http.Server{
Addr: config.Bind,
Handler: router,
},
config: config,
}
}
func (s *ServerManagerCtx) Start() {
if s.config.Cert != "" && s.config.Key != "" {
go func() {
if err := s.server.ListenAndServeTLS(s.config.Cert, s.config.Key); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start https server")
}
}()
s.logger.Info().Msgf("https listening on %s", s.server.Addr)
} else {
go func() {
if err := s.server.ListenAndServe(); err != http.ErrServerClosed {
s.logger.Panic().Err(err).Msg("unable to start http server")
}
}()
s.logger.Info().Msgf("http listening on %s", s.server.Addr)
}
}
func (s *ServerManagerCtx) Shutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.server.Shutdown(ctx)
}