init
This commit is contained in:
232
internal/app/server.go
Normal file
232
internal/app/server.go
Normal file
@@ -0,0 +1,232 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user