Files
ntfywui/internal/app/server.go
2026-01-12 13:51:52 +01:00

233 lines
5.6 KiB
Go

package app
import (
"context"
"errors"
"log"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/yourorg/ntfywui/internal/ntfy"
"github.com/yourorg/ntfywui/internal/security"
"github.com/yourorg/ntfywui/internal/store"
)
type Config struct {
BasePath string
DataDir string
Secret []byte
CookieSecure bool
TrustedProxies []*net.IPNet
NtfyBin string
NtfyConfig string
NtfyTimeout time.Duration
Logger *log.Logger
}
type Server struct {
cfg Config
mux *http.ServeMux
renderer *Renderer
sessions *security.SessionManager
admins *store.AdminStore
audit *store.AuditLog
rl *security.RateLimiter
ntfy *ntfy.Client
}
func NewServer(cfg Config) *Server {
if cfg.Logger == nil {
cfg.Logger = log.New(os.Stdout, "", log.LstdFlags)
}
sess, err := security.NewSessionManager(cfg.Secret, "ntfywui_session", cfg.CookieSecure)
if err != nil {
panic(err)
}
adminPath := filepath.Join(cfg.DataDir, "admins.json")
adminStore, err := store.NewAdminStore(adminPath)
if err != nil {
panic(err)
}
auditPath := filepath.Join(cfg.DataDir, "audit.jsonl")
audit, err := store.NewAuditLog(auditPath)
if err != nil {
panic(err)
}
r := NewRenderer(cfg.BasePath)
s := &Server{
cfg: cfg,
mux: http.NewServeMux(),
renderer: r,
sessions: sess,
admins: adminStore,
audit: audit,
rl: security.NewRateLimiter(30, 10, 10*time.Minute), // 30 burst, 10 r/s
ntfy: &ntfy.Client{
Bin: cfg.NtfyBin,
Config: cfg.NtfyConfig,
Timeout: cfg.NtfyTimeout,
},
}
s.routes()
return s
}
func (s *Server) BasePath() string { return s.cfg.BasePath }
func (s *Server) Close() error {
if s.audit != nil {
return s.audit.Close()
}
return nil
}
func (s *Server) BootstrapAdmin(user, pass string) error {
created, err := s.admins.EnsureBootstrap(user, pass)
if err != nil {
return err
}
if created {
s.cfg.Logger.Printf("bootstrap admin created: %s", user)
}
return nil
}
func (s *Server) Handler() http.Handler {
h := http.Handler(s.mux)
// BasePath support
if s.cfg.BasePath != "" {
h = http.StripPrefix(s.cfg.BasePath, h)
}
// Security middleware chain (outermost first)
ipCfg := security.RealIPConfig{TrustedProxies: s.cfg.TrustedProxies}
keyFn := func(r *http.Request) string { return security.RealIP(r, ipCfg) }
h = s.rl.Middleware(keyFn)(h)
h = security.SecureHeaders(h)
return h
}
func (s *Server) routes() {
// Public
s.mux.HandleFunc("/", s.handleIndex)
s.mux.Handle("/login", s.csrf(http.HandlerFunc(s.handleLogin)))
s.mux.HandleFunc("/logout", s.handleLogout)
// Protected areas
s.mux.Handle("/users", s.authRequired(store.RoleViewer, s.csrf(s.handleUsersList)))
s.mux.Handle("/users/", s.authRequired(store.RoleViewer, s.csrf(s.handleUserDetail))) // includes actions under /users/{name}/...
s.mux.Handle("/access", s.authRequired(store.RoleOperator, s.csrf(s.handleAccess)))
s.mux.Handle("/tokens", s.authRequired(store.RoleOperator, s.csrf(s.handleTokens)))
s.mux.Handle("/admins", s.authRequired(store.RoleAdmin, s.csrf(s.handleAdmins)))
s.mux.Handle("/audit", s.authRequired(store.RoleAdmin, s.csrf(s.handleAudit)))
// Static assets
s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(rfs())))
}
func (s *Server) csrf(next http.HandlerFunc) http.Handler {
f := security.CSRFFuncs{
GetCSRF: func(r *http.Request) (string, bool) {
sess, ok := s.sessions.Get(r)
if !ok {
return "", false
}
return sess.CSRF, sess.CSRF != ""
},
EnsureCSRF: func(w http.ResponseWriter, r *http.Request) (string, error) {
sess, ok := s.sessions.Get(r)
if !ok {
sess = &security.Session{}
}
if sess.CSRF == "" {
tok, err := security.NewCSRFToken()
if err != nil {
return "", err
}
sess.CSRF = tok
// Preserve existing login info
_ = s.sessions.Save(w, sess)
}
return sess.CSRF, nil
},
}
return security.CSRFMiddleware(f)(next)
}
func (s *Server) authRequired(minRole store.Role, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
admin, ok := s.currentAdmin(r)
if !ok {
// redirect to login
http.Redirect(w, r, s.abs("/login?next="+urlQueryEscape(r.URL.Path)), http.StatusFound)
return
}
if !roleAtLeast(admin.Role, minRole) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func roleAtLeast(have, need store.Role) bool {
order := map[store.Role]int{
store.RoleViewer: 1,
store.RoleOperator: 2,
store.RoleAdmin: 3,
}
return order[have] >= order[need]
}
func (s *Server) currentAdmin(r *http.Request) (store.Admin, bool) {
sess, ok := s.sessions.Get(r)
if !ok || sess.User == "" {
return store.Admin{}, false
}
a, ok := s.admins.Get(sess.User)
if !ok || a.Disabled {
return store.Admin{}, false
}
return a, true
}
func (s *Server) auditEvent(r *http.Request, action, target string, meta map[string]string) {
admin, _ := s.currentAdmin(r)
ip := security.RealIP(r, security.RealIPConfig{TrustedProxies: s.cfg.TrustedProxies})
s.audit.Append(store.AuditEvent{
Actor: admin.Username,
IP: ip,
UA: r.UserAgent(),
Action: action,
Target: target,
Meta: meta,
})
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if _, ok := s.currentAdmin(r); ok {
http.Redirect(w, r, s.abs("/users"), http.StatusFound)
return
}
http.Redirect(w, r, s.abs("/login"), http.StatusFound)
}
var errBadRequest = errors.New("bad request")
func (s *Server) ntfyCtx(r *http.Request) context.Context {
return r.Context()
}