init
This commit is contained in:
23
internal/app/assets.go
Normal file
23
internal/app/assets.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed web/templates/*.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed web/static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
func tfs() fs.FS {
|
||||
sub, _ := fs.Sub(templatesFS, "web/templates")
|
||||
return sub
|
||||
}
|
||||
|
||||
func rfs() http.FileSystem {
|
||||
sub, _ := fs.Sub(staticFS, "web/static")
|
||||
return http.FS(sub)
|
||||
}
|
||||
29
internal/app/flash.go
Normal file
29
internal/app/flash.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/yourorg/ntfywui/internal/security"
|
||||
)
|
||||
|
||||
func (s *Server) setFlash(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
sess, ok := s.sessions.Get(r)
|
||||
if !ok {
|
||||
sess = &security.Session{}
|
||||
}
|
||||
sess.Flash = msg
|
||||
_ = s.sessions.Save(w, sess)
|
||||
}
|
||||
|
||||
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
|
||||
sess, ok := s.sessions.Get(r)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
msg := sess.Flash
|
||||
if msg != "" {
|
||||
sess.Flash = ""
|
||||
_ = s.sessions.Save(w, sess)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
74
internal/app/handlers_access.go
Normal file
74
internal/app/handlers_access.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/ntfywui/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleAccess(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := s.currentAdmin(r)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
|
||||
if err != nil {
|
||||
s.renderer.Render(w, "error.html", PageData{Title: "Fehler", Admin: admin.Username, Role: string(admin.Role), Error: err.Error()})
|
||||
return
|
||||
}
|
||||
csrf, _ := s.csrfEnsure(w, r)
|
||||
flash := s.popFlash(w, r)
|
||||
s.renderer.Render(w, "access.html", PageData{
|
||||
Title: "Access",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
CSRF: csrf,
|
||||
Flash: flash,
|
||||
Users: users,
|
||||
})
|
||||
case http.MethodPost:
|
||||
if !roleAtLeast(admin.Role, store.RoleOperator) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
action := r.Form.Get("action")
|
||||
username := cleanUser(r.Form.Get("username"))
|
||||
switch action {
|
||||
case "grant":
|
||||
topic := cleanTopic(r.Form.Get("topic"))
|
||||
perm := strings.TrimSpace(r.Form.Get("perm"))
|
||||
if username == "" || topic == "" || perm == "" {
|
||||
s.setFlash(w, r, "Username, Topic und Permission sind erforderlich")
|
||||
http.Redirect(w, r, s.abs("/access"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := s.ntfy.GrantAccess(s.ntfyCtx(r), username, topic, perm); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/access"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_access_grant", username, map[string]string{"topic": topic, "perm": perm})
|
||||
s.setFlash(w, r, "Access gesetzt")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
case "reset":
|
||||
if username == "" {
|
||||
s.setFlash(w, r, "Username erforderlich")
|
||||
http.Redirect(w, r, s.abs("/access"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := s.ntfy.ResetAccess(s.ntfyCtx(r), username); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/access"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_access_reset", username, nil)
|
||||
s.setFlash(w, r, "Access reset")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
199
internal/app/handlers_admins.go
Normal file
199
internal/app/handlers_admins.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yourorg/ntfywui/internal/security"
|
||||
"github.com/yourorg/ntfywui/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleAdmins(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := s.currentAdmin(r)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
csrf, _ := s.csrfEnsure(w, r)
|
||||
flash := s.popFlash(w, r)
|
||||
s.renderer.Render(w, "admins.html", PageData{
|
||||
Title: "Admins",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
CSRF: csrf,
|
||||
Flash: flash,
|
||||
Admins: s.admins.List(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
_ = r.ParseForm()
|
||||
action := r.Form.Get("action")
|
||||
username := cleanUser(r.Form.Get("username"))
|
||||
switch action {
|
||||
case "create":
|
||||
pass := r.Form.Get("password")
|
||||
role := store.Role(strings.TrimSpace(r.Form.Get("role")))
|
||||
if role == "" {
|
||||
role = store.RoleOperator
|
||||
}
|
||||
if username == "" || pass == "" {
|
||||
s.setFlash(w, r, "Username und Passwort erforderlich")
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if role != store.RoleViewer && role != store.RoleOperator && role != store.RoleAdmin {
|
||||
s.setFlash(w, r, "Ungültige Rolle")
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, ok := s.admins.Get(username); ok {
|
||||
s.setFlash(w, r, "Admin existiert bereits")
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
_, _ = randRead(salt)
|
||||
hash := security.HashPasswordPBKDF2(pass, salt, 120_000)
|
||||
a := store.Admin{
|
||||
Username: username,
|
||||
Role: role,
|
||||
PassHash: hash,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
if err := s.admins.Set(a); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
} else {
|
||||
s.auditEvent(r, "webui_admin_create", username, map[string]string{"role": string(role)})
|
||||
s.setFlash(w, r, "Admin erstellt")
|
||||
}
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
|
||||
case "set-role":
|
||||
role := store.Role(strings.TrimSpace(r.Form.Get("role")))
|
||||
a, ok := s.admins.Get(username)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if role != store.RoleViewer && role != store.RoleOperator && role != store.RoleAdmin {
|
||||
s.setFlash(w, r, "Ungültige Rolle")
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
a.Role = role
|
||||
if err := s.admins.Set(a); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
} else {
|
||||
s.auditEvent(r, "webui_admin_set_role", username, map[string]string{"role": string(role)})
|
||||
s.setFlash(w, r, "Rolle geändert")
|
||||
}
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
|
||||
case "set-pass":
|
||||
pass := r.Form.Get("password")
|
||||
if pass == "" {
|
||||
s.setFlash(w, r, "Passwort erforderlich")
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
a, ok := s.admins.Get(username)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
_, _ = randRead(salt)
|
||||
a.PassHash = security.HashPasswordPBKDF2(pass, salt, 120_000)
|
||||
if err := s.admins.Set(a); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
} else {
|
||||
s.auditEvent(r, "webui_admin_set_pass", username, nil)
|
||||
s.setFlash(w, r, "Passwort geändert")
|
||||
}
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
|
||||
case "toggle-disable":
|
||||
a, ok := s.admins.Get(username)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Prevent self-lockout
|
||||
if username == admin.Username {
|
||||
s.setFlash(w, r, "Du kannst dich nicht selbst deaktivieren")
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
a.Disabled = !a.Disabled
|
||||
if err := s.admins.Set(a); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
} else {
|
||||
s.auditEvent(r, "webui_admin_toggle_disable", username, map[string]string{"disabled": boolStr(a.Disabled)})
|
||||
s.setFlash(w, r, "Aktualisiert")
|
||||
}
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
|
||||
case "2fa-enable":
|
||||
a, ok := s.admins.Get(username)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
secret, err := security.GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
a.TOTPSecret = secret
|
||||
if err := s.admins.Set(a); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
} else {
|
||||
s.auditEvent(r, "webui_admin_2fa_enable", username, nil)
|
||||
s.setFlash(w, r, "2FA Secret (speichern!): "+secret)
|
||||
}
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
|
||||
case "2fa-disable":
|
||||
a, ok := s.admins.Get(username)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.TOTPSecret = ""
|
||||
if err := s.admins.Set(a); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
} else {
|
||||
s.auditEvent(r, "webui_admin_2fa_disable", username, nil)
|
||||
s.setFlash(w, r, "2FA deaktiviert")
|
||||
}
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
|
||||
case "delete":
|
||||
// Prevent deleting self
|
||||
if username == admin.Username {
|
||||
s.setFlash(w, r, "Du kannst dich nicht selbst löschen")
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := s.admins.Delete(username); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
} else {
|
||||
s.auditEvent(r, "webui_admin_delete", username, nil)
|
||||
s.setFlash(w, r, "Admin gelöscht")
|
||||
}
|
||||
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
|
||||
|
||||
default:
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
33
internal/app/handlers_audit.go
Normal file
33
internal/app/handlers_audit.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := s.currentAdmin(r)
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
evs, err := s.audit.Tail(200)
|
||||
if err != nil {
|
||||
s.renderer.Render(w, "error.html", PageData{
|
||||
Title: "Fehler",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
csrf, _ := s.csrfEnsure(w, r)
|
||||
flash := s.popFlash(w, r)
|
||||
s.renderer.Render(w, "audit.html", PageData{
|
||||
Title: "Audit",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
CSRF: csrf,
|
||||
Flash: flash,
|
||||
Audit: evs,
|
||||
})
|
||||
}
|
||||
88
internal/app/handlers_auth.go
Normal file
88
internal/app/handlers_auth.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/ntfywui/internal/security"
|
||||
"github.com/yourorg/ntfywui/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
next := r.URL.Query().Get("next")
|
||||
if next == "" {
|
||||
next = "/users"
|
||||
}
|
||||
csrf, _ := s.csrfEnsure(w, r)
|
||||
flash := s.popFlash(w, r)
|
||||
s.renderer.Render(w, "login.html", PageData{
|
||||
Title: "Login",
|
||||
CSRF: csrf,
|
||||
Flash: flash,
|
||||
Next: next,
|
||||
})
|
||||
case http.MethodPost:
|
||||
_ = r.ParseForm()
|
||||
user := cleanUser(r.Form.Get("username"))
|
||||
pass := r.Form.Get("password")
|
||||
totp := strings.TrimSpace(r.Form.Get("totp"))
|
||||
next := r.Form.Get("next")
|
||||
if next == "" {
|
||||
next = "/users"
|
||||
}
|
||||
// CSRF checked by middleware in routes (we add it by calling s.csrf wrapper above in routes)
|
||||
a, ok := s.admins.Authenticate(user, pass, totp)
|
||||
if !ok {
|
||||
s.audit.Append(store.AuditEvent{
|
||||
Actor: user,
|
||||
IP: security.RealIP(r, security.RealIPConfig{TrustedProxies: s.cfg.TrustedProxies}),
|
||||
UA: r.UserAgent(),
|
||||
Action: "login_failed",
|
||||
})
|
||||
s.setFlash(w, r, "Login fehlgeschlagen")
|
||||
http.Redirect(w, r, s.abs("/login"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
sess, _ := s.sessions.Get(r)
|
||||
if sess == nil {
|
||||
sess = &security.Session{}
|
||||
}
|
||||
sess.User = a.Username
|
||||
sess.Role = string(a.Role)
|
||||
if sess.CSRF == "" {
|
||||
tok, _ := security.NewCSRFToken()
|
||||
sess.CSRF = tok
|
||||
}
|
||||
s.sessions.Save(w, sess)
|
||||
|
||||
s.auditEvent(r, "login_ok", a.Username, map[string]string{"role": string(a.Role)})
|
||||
http.Redirect(w, r, s.abs(next), http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.auditEvent(r, "logout", "", nil)
|
||||
s.sessions.Clear(w)
|
||||
http.Redirect(w, r, s.abs("/login"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) csrfEnsure(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
|
||||
_ = s.sessions.Save(w, sess)
|
||||
}
|
||||
return sess.CSRF, nil
|
||||
}
|
||||
77
internal/app/handlers_tokens.go
Normal file
77
internal/app/handlers_tokens.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/ntfywui/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := s.currentAdmin(r)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
|
||||
if err != nil {
|
||||
s.renderer.Render(w, "error.html", PageData{Title: "Fehler", Admin: admin.Username, Role: string(admin.Role), Error: err.Error()})
|
||||
return
|
||||
}
|
||||
csrf, _ := s.csrfEnsure(w, r)
|
||||
flash := s.popFlash(w, r)
|
||||
s.renderer.Render(w, "tokens.html", PageData{
|
||||
Title: "Tokens",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
CSRF: csrf,
|
||||
Flash: flash,
|
||||
Users: users,
|
||||
})
|
||||
case http.MethodPost:
|
||||
if !roleAtLeast(admin.Role, store.RoleOperator) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
action := r.Form.Get("action")
|
||||
username := cleanUser(r.Form.Get("username"))
|
||||
switch action {
|
||||
case "add":
|
||||
label := strings.TrimSpace(r.Form.Get("label"))
|
||||
expires := strings.TrimSpace(r.Form.Get("expires"))
|
||||
if username == "" {
|
||||
s.setFlash(w, r, "Username erforderlich")
|
||||
http.Redirect(w, r, s.abs("/tokens"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
tok, err := s.ntfy.TokenAdd(s.ntfyCtx(r), username, label, expires)
|
||||
if err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/tokens"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_token_add", username, map[string]string{"label": label, "expires": expires})
|
||||
// Show token once
|
||||
s.setFlash(w, r, "Token erstellt: "+tok)
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
case "remove":
|
||||
token := strings.TrimSpace(r.Form.Get("token"))
|
||||
if username == "" || token == "" {
|
||||
s.setFlash(w, r, "Username und Token erforderlich")
|
||||
http.Redirect(w, r, s.abs("/tokens"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := s.ntfy.TokenRemove(s.ntfyCtx(r), username, token); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_token_remove", username, nil)
|
||||
s.setFlash(w, r, "Token entfernt")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
186
internal/app/handlers_users.go
Normal file
186
internal/app/handlers_users.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/ntfywui/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/users" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
admin, _ := s.currentAdmin(r)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
|
||||
if err != nil {
|
||||
s.renderer.Render(w, "error.html", PageData{
|
||||
Title: "Fehler",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
csrf, _ := s.csrfEnsure(w, r)
|
||||
flash := s.popFlash(w, r)
|
||||
s.renderer.Render(w, "users.html", PageData{
|
||||
Title: "Users",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
CSRF: csrf,
|
||||
Flash: flash,
|
||||
Users: users,
|
||||
})
|
||||
case http.MethodPost:
|
||||
_ = r.ParseForm()
|
||||
action := r.Form.Get("action")
|
||||
switch action {
|
||||
case "create":
|
||||
if !roleAtLeast(admin.Role, store.RoleOperator) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
username := cleanUser(r.Form.Get("username"))
|
||||
role := strings.TrimSpace(r.Form.Get("role"))
|
||||
tier := strings.TrimSpace(r.Form.Get("tier"))
|
||||
pass := r.Form.Get("password")
|
||||
if username == "" || pass == "" {
|
||||
s.setFlash(w, r, "Username und Passwort sind erforderlich")
|
||||
http.Redirect(w, r, s.abs("/users"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := s.ntfy.AddUser(s.ntfyCtx(r), username, role, tier, pass); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/users"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_user_add", username, map[string]string{"role": role, "tier": tier})
|
||||
s.setFlash(w, r, "User erstellt: "+username)
|
||||
http.Redirect(w, r, s.abs("/users"), http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// /users/{name} or /users/{name}/action
|
||||
admin, _ := s.currentAdmin(r)
|
||||
p := strings.TrimPrefix(r.URL.Path, "/users/")
|
||||
if p == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||
username := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := s.ntfy.ListUsers(s.ntfyCtx(r))
|
||||
if err != nil {
|
||||
s.renderer.Render(w, "error.html", PageData{
|
||||
Title: "Fehler",
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
var u any
|
||||
for _, x := range users {
|
||||
if x.Username == username {
|
||||
u = x
|
||||
break
|
||||
}
|
||||
}
|
||||
if u == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
toks, _ := s.ntfy.TokenList(s.ntfyCtx(r), username)
|
||||
csrf, _ := s.csrfEnsure(w, r)
|
||||
flash := s.popFlash(w, r)
|
||||
s.renderer.Render(w, "user.html", PageData{
|
||||
Title: "User: " + username,
|
||||
Admin: admin.Username,
|
||||
Role: string(admin.Role),
|
||||
CSRF: csrf,
|
||||
Flash: flash,
|
||||
User: u,
|
||||
Tokens: toks,
|
||||
})
|
||||
case http.MethodPost:
|
||||
if !roleAtLeast(admin.Role, store.RoleOperator) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
switch action {
|
||||
case "delete":
|
||||
if err := s.ntfy.DelUser(s.ntfyCtx(r), username); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_user_del", username, nil)
|
||||
s.setFlash(w, r, "User gelöscht: "+username)
|
||||
http.Redirect(w, r, s.abs("/users"), http.StatusFound)
|
||||
case "password":
|
||||
pass := r.Form.Get("password")
|
||||
if pass == "" {
|
||||
s.setFlash(w, r, "Passwort erforderlich")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := s.ntfy.ChangePass(s.ntfyCtx(r), username, pass); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_user_change_pass", username, nil)
|
||||
s.setFlash(w, r, "Passwort geändert")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
case "role":
|
||||
role := strings.TrimSpace(r.Form.Get("role"))
|
||||
if role == "" {
|
||||
s.setFlash(w, r, "Rolle erforderlich")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := s.ntfy.ChangeRole(s.ntfyCtx(r), username, role); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_user_change_role", username, map[string]string{"role": role})
|
||||
s.setFlash(w, r, "Rolle geändert")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
case "tier":
|
||||
tier := strings.TrimSpace(r.Form.Get("tier"))
|
||||
if tier == "" {
|
||||
tier = "none"
|
||||
}
|
||||
if err := s.ntfy.ChangeTier(s.ntfyCtx(r), username, tier); err != nil {
|
||||
s.setFlash(w, r, "Fehler: "+err.Error())
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.auditEvent(r, "ntfy_user_change_tier", username, map[string]string{"tier": tier})
|
||||
s.setFlash(w, r, "Tier geändert")
|
||||
http.Redirect(w, r, s.abs("/users/"+username), http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
5
internal/app/rand.go
Normal file
5
internal/app/rand.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package app
|
||||
|
||||
import "crypto/rand"
|
||||
|
||||
func randRead(b []byte) (int, error) { return rand.Read(b) }
|
||||
63
internal/app/render.go
Normal file
63
internal/app/render.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
basePath string
|
||||
tpls *template.Template
|
||||
}
|
||||
|
||||
func NewRenderer(basePath string) *Renderer {
|
||||
funcs := template.FuncMap{
|
||||
"abs": func(p string) string {
|
||||
if basePath == "" {
|
||||
return p
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return basePath + p
|
||||
},
|
||||
"join": path.Join,
|
||||
}
|
||||
t := template.New("base").Funcs(funcs)
|
||||
t = template.Must(t.ParseFS(tfs(),
|
||||
"layout.html",
|
||||
"login.html",
|
||||
"users.html",
|
||||
"user.html",
|
||||
"access.html",
|
||||
"tokens.html",
|
||||
"admins.html",
|
||||
"audit.html",
|
||||
"error.html",
|
||||
))
|
||||
return &Renderer{basePath: basePath, tpls: t}
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Admin string
|
||||
Role string
|
||||
CSRF string
|
||||
Flash string
|
||||
Error string
|
||||
|
||||
Users any
|
||||
User any
|
||||
Tokens any
|
||||
Admins any
|
||||
Audit any
|
||||
Access any
|
||||
Next string
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(w http.ResponseWriter, name string, data PageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = r.tpls.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
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()
|
||||
}
|
||||
28
internal/app/util.go
Normal file
28
internal/app/util.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) abs(p string) string {
|
||||
if s.cfg.BasePath == "" {
|
||||
return p
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return s.cfg.BasePath + p
|
||||
}
|
||||
|
||||
func urlQueryEscape(s string) string {
|
||||
return url.QueryEscape(s)
|
||||
}
|
||||
|
||||
func cleanUser(u string) string {
|
||||
return strings.TrimSpace(u)
|
||||
}
|
||||
|
||||
func cleanTopic(t string) string {
|
||||
return strings.TrimSpace(t)
|
||||
}
|
||||
131
internal/app/web/static/app.css
Normal file
131
internal/app/web/static/app.css
Normal file
@@ -0,0 +1,131 @@
|
||||
/* Minimal dark UI, no external deps */
|
||||
:root{
|
||||
--bg:#0b0f17;
|
||||
--panel:#111827;
|
||||
--panel2:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--muted:#9ca3af;
|
||||
--border:#243041;
|
||||
--accent:#22c55e;
|
||||
--danger:#ef4444;
|
||||
--warn:#f59e0b;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji";
|
||||
background: radial-gradient(1200px 600px at 10% 0%, #0f1b2e 0%, var(--bg) 55%);
|
||||
color:var(--text);
|
||||
}
|
||||
a{color:inherit}
|
||||
code.mono, pre, code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}
|
||||
.page{max-width:1100px;margin:0 auto;padding:22px}
|
||||
.page.center{display:flex;align-items:center;justify-content:center;min-height:100%}
|
||||
.nav{
|
||||
position:sticky;top:0;z-index:2;
|
||||
display:flex;align-items:center;gap:16px;
|
||||
padding:14px 18px;
|
||||
border-bottom:1px solid var(--border);
|
||||
background: rgba(15,23,42,.82);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.brand a{font-weight:700;text-decoration:none}
|
||||
.links{display:flex;gap:14px;flex:1}
|
||||
.links a{opacity:.9;text-decoration:none}
|
||||
.links a:hover{opacity:1;text-decoration:underline}
|
||||
.user{display:flex;gap:10px;align-items:center}
|
||||
.badge{
|
||||
padding:6px 10px;border:1px solid var(--border);
|
||||
border-radius:999px;background:rgba(17,24,39,.7);color:var(--muted)
|
||||
}
|
||||
h1{margin:0 0 14px;font-size:28px}
|
||||
h2{margin:0 0 10px;font-size:18px}
|
||||
h3{margin:14px 0 8px;font-size:15px;color:var(--muted)}
|
||||
.card{
|
||||
background: linear-gradient(180deg, rgba(17,24,39,.85), rgba(15,23,42,.85));
|
||||
border:1px solid var(--border);
|
||||
border-radius:14px;
|
||||
padding:16px 16px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.25);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
label{display:block;margin:10px 0 6px;color:var(--muted);font-size:13px}
|
||||
input, select{
|
||||
width:100%;
|
||||
padding:10px 10px;
|
||||
border-radius:10px;
|
||||
border:1px solid var(--border);
|
||||
background: rgba(2,6,23,.65);
|
||||
color:var(--text);
|
||||
outline:none;
|
||||
}
|
||||
input:focus, select:focus{border-color: rgba(34,197,94,.5); box-shadow: 0 0 0 3px rgba(34,197,94,.12)}
|
||||
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||
@media (max-width:800px){.grid{grid-template-columns:1fr}.links{display:none}}
|
||||
.btn{
|
||||
margin-top:12px;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
padding:10px 14px;border-radius:12px;
|
||||
border:1px solid rgba(34,197,94,.5);
|
||||
background: rgba(34,197,94,.14);
|
||||
color: var(--text);
|
||||
cursor:pointer;
|
||||
}
|
||||
.btn:hover{background: rgba(34,197,94,.22)}
|
||||
.btn-ghost{
|
||||
border-color: var(--border);
|
||||
background: rgba(148,163,184,.08);
|
||||
}
|
||||
.btn-ghost:hover{background: rgba(148,163,184,.12)}
|
||||
.btn-danger{
|
||||
border-color: rgba(239,68,68,.6);
|
||||
background: rgba(239,68,68,.12);
|
||||
}
|
||||
.btn-danger:hover{background: rgba(239,68,68,.18)}
|
||||
.flash{
|
||||
margin:10px 0;
|
||||
padding:10px 12px;
|
||||
border:1px solid rgba(34,197,94,.35);
|
||||
border-radius:12px;
|
||||
background: rgba(34,197,94,.10);
|
||||
}
|
||||
.flash-err{
|
||||
border-color: rgba(239,68,68,.55);
|
||||
background: rgba(239,68,68,.10);
|
||||
}
|
||||
.hint{margin-top:10px;color:var(--muted);font-size:13px;line-height:1.35}
|
||||
.table{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
overflow:hidden;
|
||||
}
|
||||
.table th, .table td{
|
||||
text-align:left;
|
||||
padding:10px 10px;
|
||||
border-top:1px solid var(--border);
|
||||
vertical-align:top;
|
||||
}
|
||||
.table th{color:var(--muted);font-size:12px;font-weight:600}
|
||||
.pill{
|
||||
display:inline-flex;align-items:center;
|
||||
padding:4px 8px;
|
||||
border-radius:999px;
|
||||
border:1px solid rgba(148,163,184,.25);
|
||||
background: rgba(148,163,184,.08);
|
||||
color: var(--text);
|
||||
font-size:12px;
|
||||
margin:2px 6px 2px 0;
|
||||
}
|
||||
.pill-muted{opacity:.7}
|
||||
.muted{color:var(--muted)}
|
||||
.pre{
|
||||
margin:0;padding:12px;
|
||||
border-radius:12px;
|
||||
border:1px solid var(--border);
|
||||
background: rgba(2,6,23,.65);
|
||||
overflow:auto;
|
||||
}
|
||||
.row{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--border)}
|
||||
.inline{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.footer{padding:20px;color:var(--muted);border-top:1px solid var(--border)}
|
||||
61
internal/app/web/templates/access.html
Normal file
61
internal/app/web/templates/access.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{{define "access.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
{{template "partials_nav" .}}
|
||||
<main class="page">
|
||||
<h1>Access</h1>
|
||||
{{template "partials_flash" .}}
|
||||
|
||||
<section class="card">
|
||||
<h2>Grant</h2>
|
||||
<form method="post" action="{{abs "/access"}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="action" value="grant">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<select name="username" required>
|
||||
{{range .Users}}<option value="{{.Username}}">{{.Username}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Topic / Pattern</label>
|
||||
<input name="topic" placeholder="alerts_* oder mytopic" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Permission</label>
|
||||
<select name="perm" required>
|
||||
<option value="read-write">read-write</option>
|
||||
<option value="read-only">read-only</option>
|
||||
<option value="write-only">write-only</option>
|
||||
<option value="deny">deny</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Setzen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Reset</h2>
|
||||
<form method="post" action="{{abs "/access"}}" onsubmit="return confirm('Access wirklich resetten?')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="action" value="reset">
|
||||
<label>User</label>
|
||||
<select name="username" required>
|
||||
{{range .Users}}<option value="{{.Username}}">{{.Username}}</option>{{end}}
|
||||
</select>
|
||||
<button class="btn btn-danger" type="submit">Reset</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="hint">
|
||||
Hinweis: Access-Kontrolle basiert auf <code>ntfy access</code> und benötigt eine korrekt konfigurierte <code>auth-file</code> im <code>server.yml</code>.
|
||||
</div>
|
||||
</main>
|
||||
{{template "partials_footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
105
internal/app/web/templates/admins.html
Normal file
105
internal/app/web/templates/admins.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{{define "admins.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
{{template "partials_nav" .}}
|
||||
<main class="page">
|
||||
<h1>WebUI Admins</h1>
|
||||
{{template "partials_flash" .}}
|
||||
|
||||
<section class="card">
|
||||
<h2>Neuen Admin erstellen</h2>
|
||||
<form method="post" action="{{abs "/admins"}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div class="grid">
|
||||
<div><label>Username</label><input name="username" required></div>
|
||||
<div><label>Passwort</label><input name="password" type="password" required></div>
|
||||
<div>
|
||||
<label>Rolle</label>
|
||||
<select name="role">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="operator" selected>operator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Erstellen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Liste</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th>User</th><th>Role</th><th>2FA</th><th>Status</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Admins}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.Role}}</td>
|
||||
<td>{{if .TOTPSecret}}<span class="pill">enabled</span>{{else}}<span class="pill pill-muted">off</span>{{end}}</td>
|
||||
<td>{{if .Disabled}}<span class="pill pill-muted">disabled</span>{{else}}<span class="pill">active</span>{{end}}</td>
|
||||
<td>
|
||||
<form class="inline" method="post" action="{{abs "/admins"}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="set-role">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<select name="role">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="operator">operator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost" type="submit">Set role</button>
|
||||
</form>
|
||||
|
||||
<form class="inline" method="post" action="{{abs "/admins"}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="set-pass">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<input name="password" type="password" placeholder="new password">
|
||||
<button class="btn btn-ghost" type="submit">Set pass</button>
|
||||
</form>
|
||||
|
||||
<form class="inline" method="post" action="{{abs "/admins"}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="toggle-disable">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<button class="btn btn-ghost" type="submit">Toggle</button>
|
||||
</form>
|
||||
|
||||
{{if .TOTPSecret}}
|
||||
<form class="inline" method="post" action="{{abs "/admins"}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="2fa-disable">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<button class="btn btn-ghost" type="submit">2FA off</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form class="inline" method="post" action="{{abs "/admins"}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="2fa-enable">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<button class="btn btn-ghost" type="submit">2FA on</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<form class="inline" method="post" action="{{abs "/admins"}}" onsubmit="return confirm('Admin löschen?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="hint">2FA Secret wird als Flash angezeigt. In einer Authenticator-App als TOTP-Secret (base32) hinzufügen.</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
{{template "partials_footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
31
internal/app/web/templates/audit.html
Normal file
31
internal/app/web/templates/audit.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{define "audit.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
{{template "partials_nav" .}}
|
||||
<main class="page">
|
||||
<h1>Audit Log (letzte 200)</h1>
|
||||
{{template "partials_flash" .}}
|
||||
|
||||
<section class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>Time</th><th>Actor</th><th>IP</th><th>Action</th><th>Target</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Audit}}
|
||||
<tr>
|
||||
<td><code class="mono">{{.Time}}</code></td>
|
||||
<td>{{.Actor}}</td>
|
||||
<td><code class="mono">{{.IP}}</code></td>
|
||||
<td>{{.Action}}</td>
|
||||
<td>{{.Target}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
{{template "partials_footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
18
internal/app/web/templates/error.html
Normal file
18
internal/app/web/templates/error.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{define "error.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
{{template "partials_nav" .}}
|
||||
<main class="page">
|
||||
<h1>Fehler</h1>
|
||||
{{template "partials_flash" .}}
|
||||
<section class="card">
|
||||
<pre class="pre">{{.Error}}</pre>
|
||||
<div class="hint">Tipp: In Docker sicherstellen, dass <code>/etc/ntfy/server.yml</code> und <code>/var/lib/ntfy</code> korrekt gemountet sind.</div>
|
||||
</section>
|
||||
</main>
|
||||
{{template "partials_footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
38
internal/app/web/templates/layout.html
Normal file
38
internal/app/web/templates/layout.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{{define "partials_head"}}
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}} – {{end}}ntfywui</title>
|
||||
<link rel="stylesheet" href="{{abs "/static/app.css"}}">
|
||||
</head>
|
||||
{{end}}
|
||||
|
||||
{{define "partials_nav"}}
|
||||
<nav class="nav">
|
||||
<div class="brand"><a href="{{abs "/users"}}">ntfywui</a></div>
|
||||
{{if .Admin}}
|
||||
<div class="links">
|
||||
<a href="{{abs "/users"}}">Users</a>
|
||||
{{if or (eq .Role "operator") (eq .Role "admin")}}<a href="{{abs "/access"}}">Access</a>{{end}}
|
||||
{{if or (eq .Role "operator") (eq .Role "admin")}}<a href="{{abs "/tokens"}}">Tokens</a>{{end}}
|
||||
{{if eq .Role "admin"}}<a href="{{abs "/admins"}}">Admins</a>{{end}}
|
||||
{{if eq .Role "admin"}}<a href="{{abs "/audit"}}">Audit</a>{{end}}
|
||||
</div>
|
||||
<div class="user">
|
||||
<span class="badge">{{.Admin}} ({{.Role}})</span>
|
||||
<a class="btn btn-ghost" href="{{abs "/logout"}}">Logout</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{define "partials_flash"}}
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="flash flash-err">{{.Error}}</div>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "partials_footer"}}
|
||||
<footer class="footer">
|
||||
<div>ntfywui – Webverwaltung für ntfy (CLI-basiert). Standardbibliothek-only.</div>
|
||||
</footer>
|
||||
{{end}}
|
||||
29
internal/app/web/templates/login.html
Normal file
29
internal/app/web/templates/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "login.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
<div class="page center">
|
||||
<div class="card">
|
||||
<h1>Login</h1>
|
||||
{{template "partials_flash" .}}
|
||||
<form method="post" action="{{abs "/login"}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<label>Username</label>
|
||||
<input name="username" autocomplete="username" required>
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
<label>TOTP (optional)</label>
|
||||
<input name="totp" inputmode="numeric" autocomplete="one-time-code" placeholder="123456">
|
||||
<button class="btn" type="submit">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<div class="hint">
|
||||
Tipp: Setze <code>NTFYWUI_BOOTSTRAP_USER</code> und <code>NTFYWUI_BOOTSTRAP_PASS</code> für den ersten Admin.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
40
internal/app/web/templates/tokens.html
Normal file
40
internal/app/web/templates/tokens.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{{define "tokens.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
{{template "partials_nav" .}}
|
||||
<main class="page">
|
||||
<h1>Tokens</h1>
|
||||
{{template "partials_flash" .}}
|
||||
|
||||
<section class="card">
|
||||
<h2>Token erstellen</h2>
|
||||
<form method="post" action="{{abs "/tokens"}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<select name="username" required>
|
||||
{{range .Users}}<option value="{{.Username}}">{{.Username}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Label (optional)</label>
|
||||
<input name="label">
|
||||
</div>
|
||||
<div>
|
||||
<label>Expires (optional)</label>
|
||||
<input name="expires" placeholder="120d, 24h, ...">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Create</button>
|
||||
</form>
|
||||
<div class="hint">Token wird als Flash angezeigt, danach nicht mehr.</div>
|
||||
</section>
|
||||
</main>
|
||||
{{template "partials_footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
102
internal/app/web/templates/user.html
Normal file
102
internal/app/web/templates/user.html
Normal file
@@ -0,0 +1,102 @@
|
||||
{{define "user.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
{{template "partials_nav" .}}
|
||||
<main class="page">
|
||||
{{template "partials_flash" .}}
|
||||
|
||||
{{with .User}}{{ $uname := .Username }}
|
||||
<h1>User: {{.Username}}</h1>
|
||||
|
||||
<section class="card">
|
||||
<h2>Details</h2>
|
||||
<div class="grid">
|
||||
<div><span class="muted">Role</span><div>{{.Role}}</div></div>
|
||||
<div><span class="muted">Tier</span><div>{{.Tier}}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if or (eq $.Role "operator") (eq $.Role "admin")}}
|
||||
<section class="card">
|
||||
<h2>Aktionen</h2>
|
||||
|
||||
<div class="grid">
|
||||
<form method="post" action="{{abs (print "/users/" .Username "/password")}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<label>Neues Passwort</label>
|
||||
<input name="password" type="password" required>
|
||||
<button class="btn" type="submit">Passwort ändern</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{abs (print "/users/" .Username "/role")}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<label>Role</label>
|
||||
<select name="role">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button class="btn" type="submit">Rolle setzen</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{abs (print "/users/" .Username "/tier")}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<label>Tier</label>
|
||||
<input name="tier" placeholder="none/pro/...">
|
||||
<button class="btn" type="submit">Tier setzen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{abs (print "/users/" .Username "/delete")}}" onsubmit="return confirm('User wirklich löschen?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn btn-danger" type="submit">User löschen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Access</h2>
|
||||
{{range .Access}}<div class="pill">{{.Perm}} → {{.Topic}}</div>{{end}}
|
||||
<div class="hint">Access wird mit <code>ntfy access</code> verwaltet (siehe Access-Seite).</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Tokens</h2>
|
||||
{{if $.Tokens}}
|
||||
{{range $.Tokens}}
|
||||
<div class="row">
|
||||
<code class="mono">{{.Token}}</code>
|
||||
<form method="post" action="{{abs "/tokens"}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="remove">
|
||||
<input type="hidden" name="username" value="{{$uname}}">
|
||||
<input type="hidden" name="token" value="{{.Token}}">
|
||||
<button class="btn btn-ghost" type="submit">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="muted">Keine Tokens (oder nicht auslesbar).</div>
|
||||
{{end}}
|
||||
|
||||
<h3>Token hinzufügen</h3>
|
||||
<form method="post" action="{{abs "/tokens"}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="hidden" name="username" value="{{.Username}}">
|
||||
<div class="grid">
|
||||
<div><label>Label (optional)</label><input name="label"></div>
|
||||
<div><label>Expires (optional)</label><input name="expires" placeholder="120d, 24h, ..."></div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Token erstellen</button>
|
||||
<div class="hint">Der Token wird nur einmal als Flash angezeigt – direkt kopieren.</div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
</main>
|
||||
{{template "partials_footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
65
internal/app/web/templates/users.html
Normal file
65
internal/app/web/templates/users.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{{define "users.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
{{template "partials_head" .}}
|
||||
<body>
|
||||
{{template "partials_nav" .}}
|
||||
<main class="page">
|
||||
<h1>Users</h1>
|
||||
{{template "partials_flash" .}}
|
||||
|
||||
{{if or (eq .Role "operator") (eq .Role "admin")}}
|
||||
<section class="card">
|
||||
<h2>Neuen ntfy User erstellen</h2>
|
||||
<form method="post" action="{{abs "/users"}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Username</label>
|
||||
<input name="username" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Passwort</label>
|
||||
<input name="password" type="password" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Rolle</label>
|
||||
<select name="role">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Tier (optional)</label>
|
||||
<input name="tier" placeholder="none/pro/...">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Erstellen</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="card">
|
||||
<h2>Liste</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th>User</th><th>Role</th><th>Tier</th><th>Access</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><a href="{{abs (print "/users/" .Username)}}">{{.Username}}</a></td>
|
||||
<td>{{.Role}}</td>
|
||||
<td>{{.Tier}}</td>
|
||||
<td>
|
||||
{{range .Access}}<span class="pill">{{.Perm}} → {{.Topic}}</span> {{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
{{template "partials_footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user