514 lines
17 KiB
Go
514 lines
17 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/example/notify-gateway/internal/auth"
|
|
"github.com/example/notify-gateway/internal/config"
|
|
"github.com/example/notify-gateway/internal/divera"
|
|
"github.com/example/notify-gateway/internal/gateway"
|
|
"github.com/example/notify-gateway/internal/mailingress"
|
|
"github.com/example/notify-gateway/internal/model"
|
|
"github.com/example/notify-gateway/internal/outbox"
|
|
)
|
|
|
|
type Server struct {
|
|
queue *gateway.Queue
|
|
mail *mailingress.Poller
|
|
loginMu sync.Mutex
|
|
logins map[string]loginBucket
|
|
store *config.Store
|
|
dispatcher *gateway.Dispatcher
|
|
divera *divera.Client
|
|
logger *log.Logger
|
|
}
|
|
|
|
func New(store *config.Store, dispatcher *gateway.Dispatcher, client *divera.Client, logger *log.Logger) *Server {
|
|
return &Server{store: store, dispatcher: dispatcher, divera: client, logger: logger}
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", s.health)
|
|
mux.HandleFunc("GET /readyz", s.ready)
|
|
mux.HandleFunc("GET /metrics", s.metrics)
|
|
mux.HandleFunc("GET /ui/api/csrf", s.requireAdmin(s.csrf))
|
|
mux.HandleFunc("GET /ui/api/deliveries", s.requireAdmin(s.deliveries))
|
|
mux.HandleFunc("GET /ui/api/deliveries/{id}/attempts", s.requireAdmin(s.deliveryHistory))
|
|
mux.HandleFunc("POST /ui/api/deliveries/{id}/retry", s.requireAdmin(s.retryDelivery))
|
|
mux.HandleFunc("POST /in/discord", s.discord)
|
|
mux.HandleFunc("POST /login", s.login)
|
|
mux.HandleFunc("GET /login", s.loginPage)
|
|
mux.HandleFunc("POST /logout", s.requireAdmin(s.logout))
|
|
mux.HandleFunc("GET /ui/", s.requireAdmin(s.ui))
|
|
mux.HandleFunc("GET /ui/api/config", s.requireAdmin(s.apiConfig))
|
|
mux.HandleFunc("PUT /ui/api/config", s.requireAdmin(s.apiConfig))
|
|
mux.HandleFunc("GET /ui/api/divera247/catalog", s.requireAdmin(s.apiDiveraCatalog))
|
|
mux.HandleFunc("POST /ui/api/preview", s.requireAdmin(s.apiPreview))
|
|
mux.HandleFunc("POST /ui/password", s.requireAdmin(s.changePassword))
|
|
mux.HandleFunc("POST /ui/test-divera", s.requireAdmin(s.testDivera))
|
|
mux.HandleFunc("POST /in/webhook/{channel}", s.webhook)
|
|
mux.HandleFunc("PUT /in/webhook/{channel}", s.webhook)
|
|
mux.HandleFunc("POST /in/ntfy", s.ntfyJSON)
|
|
mux.HandleFunc("PUT /in/ntfy", s.ntfyJSON)
|
|
mux.HandleFunc("POST /ntfy", s.ntfyJSON)
|
|
mux.HandleFunc("PUT /ntfy", s.ntfyJSON)
|
|
mux.HandleFunc("POST /in/ntfy/{topic}", s.ntfyTopic)
|
|
mux.HandleFunc("PUT /in/ntfy/{topic}", s.ntfyTopic)
|
|
mux.HandleFunc("GET /in/ntfy/{topic}/trigger", s.ntfyTrigger)
|
|
mux.HandleFunc("GET /in/ntfy/{topic}/send", s.ntfyTrigger)
|
|
mux.HandleFunc("GET /in/ntfy/{topic}/publish", s.ntfyTrigger)
|
|
mux.HandleFunc("POST /ntfy/{topic}", s.ntfyTopic)
|
|
mux.HandleFunc("PUT /ntfy/{topic}", s.ntfyTopic)
|
|
mux.HandleFunc("GET /ntfy/{topic}/trigger", s.ntfyTrigger)
|
|
mux.HandleFunc("GET /ntfy/{topic}/send", s.ntfyTrigger)
|
|
mux.HandleFunc("GET /ntfy/{topic}/publish", s.ntfyTrigger)
|
|
mux.HandleFunc("POST /in/gotify/message", s.gotify)
|
|
mux.HandleFunc("POST /message", s.gotify)
|
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/", http.StatusFound) })
|
|
return securityHeaders(requestProtection(mux))
|
|
}
|
|
|
|
func securityHeaders(next http.Handler) http.Handler {
|
|
script := strings.Split(strings.Split(uiHTML, "<script>")[1], "</script>")[0]
|
|
// HTML parsing normalizes CRLF before CSP hashes are checked.
|
|
sum := sha256.Sum256([]byte(strings.ReplaceAll(script, "\r\n", "\n")))
|
|
scriptHash := base64.StdEncoding.EncodeToString(sum[:])
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'sha256-"+scriptHash+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "same-origin")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"ok": true, "time": time.Now().UTC()})
|
|
}
|
|
|
|
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { renderLogin(w, "") }
|
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|
if !s.allowLogin(r.RemoteAddr) {
|
|
w.Header().Set("Retry-After", "60")
|
|
http.Error(w, "too many login attempts", 429)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
cfg := s.store.Get()
|
|
if r.Form.Get("username") != cfg.Server.AdminUsername || !auth.CheckPassword(cfg.Server.AdminPasswordHash, r.Form.Get("password")) {
|
|
renderLogin(w, "Login fehlgeschlagen")
|
|
return
|
|
}
|
|
if strings.HasPrefix(cfg.Server.AdminPasswordHash, "sha256$") && len(r.Form.Get("password")) <= 72 {
|
|
h, err := auth.HashPassword(r.Form.Get("password"))
|
|
if err != nil {
|
|
http.Error(w, "password migration failed", 500)
|
|
return
|
|
}
|
|
if err = s.store.Update(func(c *config.Config) error { c.Server.AdminPasswordHash = h; return nil }); err != nil {
|
|
http.Error(w, "password migration failed", 500)
|
|
return
|
|
}
|
|
}
|
|
token := auth.SignSession(cfg.Server.SessionSecret, cfg.Server.AdminUsername, time.Now().Add(12*time.Hour))
|
|
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil, MaxAge: 43200})
|
|
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
|
|
}
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, SameSite: http.SameSiteStrictMode})
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
cfg := s.store.Get()
|
|
c, err := r.Cookie("ng_session")
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
u, ok := auth.VerifySession(cfg.Server.SessionSecret, c.Value, time.Now())
|
|
if !ok || u != cfg.Server.AdminUsername {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if r.Method != "GET" && r.Method != "HEAD" {
|
|
token := r.Header.Get("X-CSRF-Token")
|
|
if token == "" && strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
|
|
_ = r.ParseForm()
|
|
token = r.Form.Get("csrf_token")
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(token), []byte(csrfToken(cfg.Server.SessionSecret, c.Value))) != 1 {
|
|
http.Error(w, "invalid CSRF token", 403)
|
|
return
|
|
}
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
//go:embed ui.html
|
|
var uiHTML string
|
|
|
|
func (s *Server) ui(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = io.WriteString(w, uiHTML)
|
|
}
|
|
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
p := r.Form.Get("password")
|
|
if len(p) < 10 || len(p) > 72 {
|
|
http.Error(w, "Passwort muss 10 bis 72 UTF-8-Bytes enthalten", http.StatusBadRequest)
|
|
return
|
|
}
|
|
h, err := auth.HashPassword(p)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.store.Update(func(c *config.Config) error {
|
|
c.Server.AdminPasswordHash = h
|
|
c.Server.SessionSecret = auth.RandomSecret()
|
|
return nil
|
|
}); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) testDivera(w http.ResponseWriter, r *http.Request) {
|
|
resp, err := s.divera.PullAll(r.Context(), nil)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 502)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = w.Write(resp.Body)
|
|
}
|
|
|
|
func (s *Server) authorized(source, channel string, r *http.Request) bool {
|
|
cfg := s.store.Get()
|
|
if cfg.Ingress.AllowUnauthenticated {
|
|
return true
|
|
}
|
|
token := bearerOrToken(r)
|
|
var allowed []string
|
|
switch source {
|
|
case "ntfy":
|
|
if t := cfg.Ingress.NtfyTokens[channel]; t != "" {
|
|
allowed = append(allowed, t)
|
|
}
|
|
if t := cfg.Ingress.NtfyTokens["*"]; t != "" {
|
|
allowed = append(allowed, t)
|
|
}
|
|
case "webhook":
|
|
if t := cfg.Ingress.WebhookTokens[channel]; t != "" {
|
|
allowed = append(allowed, t)
|
|
}
|
|
if t := cfg.Ingress.WebhookTokens["*"]; t != "" {
|
|
allowed = append(allowed, t)
|
|
}
|
|
case "gotify":
|
|
allowed = append(allowed, cfg.Ingress.GotifyTokens...)
|
|
}
|
|
for _, a := range allowed {
|
|
if len(a) == len(token) && subtle.ConstantTimeCompare([]byte(a), []byte(token)) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func bearerOrToken(r *http.Request) string {
|
|
if t := r.URL.Query().Get("token"); t != "" {
|
|
return t
|
|
}
|
|
if t := r.Header.Get("X-Gotify-Key"); t != "" {
|
|
return t
|
|
}
|
|
a := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(strings.ToLower(a), "bearer ") {
|
|
return strings.TrimSpace(a[7:])
|
|
}
|
|
if u, p, ok := r.BasicAuth(); ok {
|
|
if p != "" {
|
|
return p
|
|
}
|
|
return u
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
|
|
channel := r.PathValue("channel")
|
|
if !s.authorized("webhook", channel, r) {
|
|
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
|
return
|
|
}
|
|
raw, body, err := decodeFlexible(r)
|
|
if err != nil {
|
|
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
msg := model.InboundMessage{Source: "webhook", Channel: channel, Title: str(raw, "title", "subject"), Message: firstNonEmpty(str(raw, "message", "text", "body"), body), Address: str(raw, "address", "location"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
|
|
s.deliver(w, r, msg)
|
|
}
|
|
func (s *Server) ntfyJSON(w http.ResponseWriter, r *http.Request) {
|
|
var raw map[string]any
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&raw); err != nil {
|
|
writeJSON(w, 400, map[string]any{"error": "invalid ntfy json"})
|
|
return
|
|
}
|
|
topic := str(raw, "topic")
|
|
if topic == "" {
|
|
writeJSON(w, 400, map[string]any{"error": "topic required"})
|
|
return
|
|
}
|
|
if !s.authorized("ntfy", topic, r) {
|
|
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
|
return
|
|
}
|
|
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: str(raw, "title"), Message: str(raw, "message"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
|
|
s.deliver(w, r, msg)
|
|
}
|
|
func (s *Server) ntfyTopic(w http.ResponseWriter, r *http.Request) {
|
|
topic := r.PathValue("topic")
|
|
if !s.authorized("ntfy", topic, r) {
|
|
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
|
return
|
|
}
|
|
raw, body, err := decodeNtfy(r)
|
|
if err != nil {
|
|
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
title := firstNonEmpty(r.Header.Get("Title"), r.Header.Get("X-Title"), str(raw, "title"))
|
|
message := firstNonEmpty(str(raw, "message"), body)
|
|
priority := intval(raw, "priority")
|
|
if priority == 0 {
|
|
priority = parsePriority(firstNonEmpty(r.Header.Get("Priority"), r.Header.Get("X-Priority")))
|
|
}
|
|
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: title, Message: message, Priority: priority, Raw: raw, ReceivedAt: time.Now().UTC()}
|
|
s.deliver(w, r, msg)
|
|
}
|
|
func (s *Server) ntfyTrigger(w http.ResponseWriter, r *http.Request) {
|
|
topic := r.PathValue("topic")
|
|
if !s.authorized("ntfy", topic, r) {
|
|
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
|
return
|
|
}
|
|
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: r.URL.Query().Get("title"), Message: firstNonEmpty(r.URL.Query().Get("message"), "triggered"), Priority: parsePriority(r.URL.Query().Get("priority")), Raw: map[string]any{"query": r.URL.Query()}, ReceivedAt: time.Now().UTC()}
|
|
s.deliver(w, r, msg)
|
|
}
|
|
func (s *Server) gotify(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized("gotify", "", r) {
|
|
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
|
return
|
|
}
|
|
raw, body, err := decodeFlexible(r)
|
|
if err != nil {
|
|
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
if len(raw) == 0 {
|
|
_ = r.ParseForm()
|
|
raw = map[string]any{"title": r.Form.Get("title"), "message": r.Form.Get("message"), "priority": r.Form.Get("priority")}
|
|
}
|
|
msg := model.InboundMessage{Source: "gotify", Channel: firstNonEmpty(str(raw, "channel"), "default"), Title: str(raw, "title"), Message: firstNonEmpty(str(raw, "message"), body), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
|
|
s.deliver(w, r, msg)
|
|
}
|
|
func (s *Server) deliver(w http.ResponseWriter, r *http.Request, msg model.InboundMessage) {
|
|
if s.queue != nil {
|
|
// Never accept a gateway-generated HTTP notification back into the gateway.
|
|
if r.Header.Get("X-Notify-Gateway-Delivery") != "" {
|
|
writeJSON(w, 409, map[string]any{"error": "gateway loop detected"})
|
|
return
|
|
}
|
|
receipt, err := s.queue.Accept(r.Context(), msg, r.Header.Get("Idempotency-Key"))
|
|
if err != nil {
|
|
status := 503
|
|
var input *gateway.InputError
|
|
if errors.As(err, &input) {
|
|
status = 400
|
|
}
|
|
if errors.Is(err, outbox.ErrConflict) {
|
|
status = 409
|
|
}
|
|
message := "outbox unavailable"
|
|
if status != 503 {
|
|
message = err.Error()
|
|
}
|
|
writeJSON(w, status, map[string]any{"error": message})
|
|
return
|
|
}
|
|
writeJSON(w, 202, map[string]any{"ok": true, "receipt": receipt})
|
|
return
|
|
}
|
|
results, err := s.dispatcher.Dispatch(r.Context(), msg)
|
|
if err != nil {
|
|
s.logger.Printf("delivery failed: %v", err)
|
|
writeJSON(w, 502, map[string]any{"error": err.Error(), "results": results})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true, "results": results})
|
|
}
|
|
|
|
func decodeNtfy(r *http.Request) (map[string]any, string, error) {
|
|
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
raw := map[string]any{}
|
|
if strings.Contains(r.Header.Get("Content-Type"), "application/json") || (len(b) > 0 && b[0] == '{') {
|
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return raw, "", nil
|
|
}
|
|
return raw, string(b), nil
|
|
}
|
|
|
|
func decodeFlexible(r *http.Request) (map[string]any, string, error) {
|
|
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
raw := map[string]any{}
|
|
ct := r.Header.Get("Content-Type")
|
|
if strings.Contains(ct, "application/json") || (len(b) > 0 && b[0] == '{') {
|
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return raw, "", nil
|
|
}
|
|
if strings.Contains(ct, "application/x-www-form-urlencoded") {
|
|
vals, err := urlParse(string(b))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
for k, v := range vals {
|
|
if len(v) > 0 {
|
|
raw[k] = v[0]
|
|
}
|
|
}
|
|
return raw, "", nil
|
|
}
|
|
return raw, string(b), nil
|
|
}
|
|
func urlParse(s string) (map[string][]string, error) {
|
|
vals, err := netURLParseQuery(s)
|
|
return map[string][]string(vals), err
|
|
}
|
|
|
|
var netURLParseQuery = func(s string) (map[string][]string, error) {
|
|
out := map[string][]string{}
|
|
for _, p := range strings.Split(s, "&") {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
kv := strings.SplitN(p, "=", 2)
|
|
k := strings.ReplaceAll(kv[0], "+", " ")
|
|
v := ""
|
|
if len(kv) > 1 {
|
|
v = strings.ReplaceAll(kv[1], "+", " ")
|
|
}
|
|
ku, err := urlQueryUnescape(k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vu, err := urlQueryUnescape(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[ku] = append(out[ku], vu)
|
|
}
|
|
return out, nil
|
|
}
|
|
var urlQueryUnescape = func(s string) (string, error) { return queryUnescape(s) }
|
|
|
|
func queryUnescape(s string) (string, error) { // minimal wrapper avoids exposing net/url in helpers
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '%' && i+2 < len(s) {
|
|
n, err := strconv.ParseUint(s[i+1:i+3], 16, 8)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.WriteByte(byte(n))
|
|
i += 2
|
|
} else {
|
|
b.WriteByte(s[i])
|
|
}
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
func str(m map[string]any, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := m[k]; ok {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x
|
|
case json.Number:
|
|
return x.String()
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func intval(m map[string]any, k string) int {
|
|
v, ok := m[k]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return int(x)
|
|
case int:
|
|
return x
|
|
case string:
|
|
n, _ := strconv.Atoi(x)
|
|
return n
|
|
case json.Number:
|
|
n, _ := strconv.Atoi(x.String())
|
|
return n
|
|
}
|
|
return 0
|
|
}
|
|
func parsePriority(s string) int { n, _ := strconv.Atoi(s); return n }
|
|
func firstNonEmpty(v ...string) string {
|
|
for _, s := range v {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
var loginTpl = template.Must(template.New("login").Parse(`<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Notify Gateway Login</title><style>body{font:16px system-ui;max-width:420px;margin:10vh auto;padding:1rem}input,button{width:100%;padding:.7rem;margin:.35rem 0;box-sizing:border-box}.err{color:#a00}</style></head><body><h1>Notify Gateway</h1>{{if .}}<p class="err">{{.}}</p>{{end}}<form method="post" action="/login"><input name="username" placeholder="Benutzer" autocomplete="username"><input type="password" name="password" placeholder="Passwort" autocomplete="current-password"><button>Anmelden</button></form></body></html>`))
|
|
|
|
func renderLogin(w http.ResponseWriter, msg string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = loginTpl.Execute(w, msg)
|
|
}
|