Files
neoirc/internal/server/routes.go
sneak 2be34bcedf
check / check (push) Waiting to run
Refuse API writes that are not application/json (closes #271)
A page on another origin of the same site could send a text/plain or
form POST with the user's cookie, since a browser sends such a request
without a preflight. RequireJSON on the /api/v1 router answers 415 to a
POST, PUT or DELETE whose Content-Type is not application/json, or that
has none, before the handler runs.

The web client and neoirc-cli already send the header on every request.

Model: opus-5-5
2026-11-11 01:33:10 +00:00

171 lines
3.2 KiB
Go

package server
import (
"io/fs"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper"
)
const routeTimeout = 60 * time.Second
// SetupRoutes configures the HTTP routes and middleware.
func (srv *Server) SetupRoutes() {
srv.router = chi.NewRouter()
srv.router.Use(middleware.Recoverer)
srv.router.Use(middleware.RequestID)
srv.router.Use(srv.mw.ClientIP())
srv.router.Use(srv.mw.Logging())
if viper.GetString("METRICS_USERNAME") != "" {
srv.router.Use(srv.mw.Metrics())
}
srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout))
if srv.sentryEnabled {
sentryHandler := sentryhttp.New(
sentryhttp.Options{ //nolint:exhaustruct // optional fields
Repanic: true,
},
)
srv.router.Use(sentryHandler.Handle)
}
// Health check.
srv.router.With(srv.mw.CORS()).Get(
"/.well-known/healthcheck.json",
srv.handlers.HandleHealthCheck(),
)
// Protected metrics endpoint.
if viper.GetString("METRICS_USERNAME") != "" {
srv.router.Group(func(router chi.Router) {
router.Use(srv.mw.MetricsAuth())
router.Get("/metrics",
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
))
})
}
// API v1.
srv.router.Route("/api/v1", srv.setupAPIv1)
// Serve embedded SPA.
srv.setupSPA()
}
func (srv *Server) setupAPIv1(router chi.Router) {
router.Use(srv.mw.RequireJSON())
router.With(srv.mw.CORS()).Get(
"/server",
srv.handlers.HandleServerInfo(),
)
router.Post(
"/session",
srv.handlers.HandleCreateSession(),
)
router.Post(
"/login",
srv.handlers.HandleLogin(),
)
router.Get(
"/state",
srv.handlers.HandleState(),
)
router.Post(
"/logout",
srv.handlers.HandleLogout(),
)
router.Get(
"/users/me",
srv.handlers.HandleUsersMe(),
)
router.Get(
"/messages",
srv.handlers.HandleGetMessages(),
)
router.Post(
"/messages",
srv.handlers.HandleSendCommand(),
)
router.Get(
"/history",
srv.handlers.HandleGetHistory(),
)
router.Get(
"/channels",
srv.handlers.HandleListAllChannels(),
)
router.Get(
"/channels/{channel}/members",
srv.handlers.HandleChannelMembers(),
)
}
func (srv *Server) setupSPA() {
distFS, err := fs.Sub(web.Dist, "dist")
if err != nil {
srv.log.Error(
"failed to get web dist filesystem",
"error", err,
)
return
}
fileServer := http.FileServer(http.FS(distFS))
srv.router.Get("/*", func(
writer http.ResponseWriter,
request *http.Request,
) {
readFS, ok := distFS.(fs.ReadFileFS)
if !ok {
fileServer.ServeHTTP(writer, request)
return
}
// The path is empty, not "/", for a request line such
// as "GET http://host HTTP/1.1".
fileData, readErr := readFS.ReadFile(
strings.TrimPrefix(request.URL.Path, "/"),
)
if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile(
"index.html",
)
if indexErr != nil {
http.NotFound(writer, request)
return
}
writer.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(indexHTML)
return
}
fileServer.ServeHTTP(writer, request)
})
}