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() }