233 lines
5.6 KiB
Go
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()
|
|
}
|