feat(notifications): add configurable delivery channels
CI / validate (pull_request) Failing after 8m1s

This commit is contained in:
2026-08-14 13:42:31 +02:00
parent 899e9889b4
commit 7eede28bf4
10 changed files with 422 additions and 61 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
- Encrypted write-only SMTP, generic HTTPS webhook and Discord channels with event filters, queued test delivery, bounded retry and redacted terminal errors.
- Encrypted write-only SMTP, generic HTTPS webhook, Discord and Gotify channels with event filters, queued test delivery, bounded retry and redacted terminal errors; SMTP event email is filtered by persistent personal preferences and instance access.
- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures.
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
- Responsive server-rendered application shell with synthwave-derived design tokens, permission-aware navigation, the official DoGaMa wordmark in the sidebar, searchable real instance cards, lifecycle summaries, server-only recent Audit activity and resilient agent/database/storage/backup/audit status on the Dashboard.
@@ -7,9 +7,24 @@ Administrators configure channels entirely in the UI:
- SMTP email;
- generic HTTPS webhook;
- Discord webhook.
- Gotify application messages.
Channel secrets are encrypted and write-only. A test action sends a clearly marked test message and reports a redacted result.
SMTP supports explicitly selected `none`, STARTTLS and direct TLS/SMTPS. TLS
uses normal certificate verification and TLS 1.2 or later; DoGaMa never
disables certificate verification. The password field remains blank after save:
leave it blank while editing to retain the configured secret, or provide a new
value to replace it. Tests use a configured test recipient; normal event email
is addressed only to eligible users, never to a global recipient list.
Discord uses an Embed with the game, instance, action, timestamp and game art
when available. Gotify uses an application token (also write-only) and sends a
readable Markdown-compatible text message. To configure Gotify: create an
application in Gotify, copy its token, then enter the server URL and token in
Administration → Notifications. Discord setup help is linked directly in that
screen.
The main application reads its automatically generated 32-byte
authenticated-encryption key from the private application data path. An invalid
or inaccessible existing key stops startup; DoGaMa never silently replaces a
@@ -33,6 +48,13 @@ Suggested configurable events:
Default notifications favor failures and required action. Normal health polls and metric refreshes never notify.
Each user controls email categories in Settings → Notifications. Defaults are
enabled for server errors, restores, updates and important administration
actions; start, stop and backup notifications are opt-in. For an instance
event, delivery selects active administrators and that instance's members,
then applies each recipient's own preference and valid email address. It does
not grant access or change RBAC. `auth.login` is not a server notification.
Deliveries are queued after the originating transaction, use bounded exponential retry and cannot fail the lifecycle operation. Payloads contain display names and operation IDs, not secrets, raw credentials or large logs. Generic webhook requests are signed and include a timestamp and event ID for receiver deduplication.
Webhook URL validation blocks loopback, private/link-local/metadata destinations by default, validates every redirect and resists DNS rebinding. An explicit future private-webhook feature would need a separately reviewed allowlist.
+260 -15
View File
@@ -19,6 +19,7 @@ import (
"io"
"net"
"net/http"
"net/mail"
"net/netip"
"net/smtp"
"net/url"
@@ -39,7 +40,18 @@ type Input struct {
Events []string
Config map[string]string
}
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
// Event is the channel-neutral notification model. Renderers derive their own
// wire format from it; callers never compose Discord or mail payloads.
type Event struct {
Type, Title, Message, InstanceName, OperationID string
Game, Action, Image, InstanceID, Recipient string
Timestamp time.Time
Severity string
}
var Categories = []string{"server_start", "server_stop", "server_error", "backup", "restore", "update", "administration"}
type Service struct {
db *sql.DB
aead cipher.AEAD
@@ -76,6 +88,19 @@ func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, err
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
return Channel{}, errors.New("invalid notification channel")
}
// Empty secret inputs intentionally preserve the existing write-only value.
if id != "" {
var encrypted []byte
if err := s.db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, id).Scan(&encrypted); err == nil {
if old, err := s.open(encrypted); err == nil {
for _, key := range secretKeys(in.Type) {
if in.Config[key] == "" {
in.Config[key] = old[key]
}
}
}
}
}
if err := validateConfigShape(in.Type, in.Config); err != nil {
return Channel{}, err
}
@@ -94,6 +119,68 @@ func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, err
}
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
}
// Preferences returns all personal email categories, applying documented
// defaults when a user has not made a choice yet.
func (s *Service) Preferences(ctx context.Context, userID string) (map[string]bool, error) {
out := defaultPreferences()
rows, err := s.db.QueryContext(ctx, `SELECT category,enabled FROM user_notification_preferences WHERE user_id=?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var category string
var enabled bool
if err := rows.Scan(&category, &enabled); err != nil {
return nil, err
}
out[category] = enabled
}
return out, rows.Err()
}
func (s *Service) SetPreferences(ctx context.Context, userID string, values map[string]bool) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := s.now().UTC().Format(time.RFC3339Nano)
for _, category := range Categories {
enabled := values[category]
if _, err := tx.ExecContext(ctx, `INSERT INTO user_notification_preferences(user_id,category,enabled,updated_at) VALUES(?,?,?,?) ON CONFLICT(user_id,category) DO UPDATE SET enabled=excluded.enabled,updated_at=excluded.updated_at`, userID, category, enabled, now); err != nil {
return err
}
}
return tx.Commit()
}
func defaultPreferences() map[string]bool {
return map[string]bool{"server_start": false, "server_stop": false, "server_error": true, "backup": false, "restore": true, "update": true, "administration": true}
}
// Language is the system notification language, independent of UI sessions.
func (s *Service) Language(ctx context.Context) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='notification_language'`).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "en", nil
}
if err != nil {
return "", err
}
value = strings.Trim(value, `"`)
if value != "en" && value != "fr" {
return "en", nil
}
return value, nil
}
func (s *Service) SetLanguage(ctx context.Context, language string) error {
if language != "en" && language != "fr" {
return errors.New("unsupported notification language")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO system_settings(key,value_json,updated_at) VALUES('notification_language',?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,revision=system_settings.revision+1,updated_at=excluded.updated_at`, `"`+language+`"`, s.now().UTC().Format(time.RFC3339Nano))
return err
}
func (s *Service) List(ctx context.Context) ([]Channel, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
if err != nil {
@@ -145,12 +232,66 @@ func (s *Service) Queue(ctx context.Context, event Event) error {
_ = rows.Close()
now := s.now().UTC().Format(time.RFC3339Nano)
for _, id := range channelIDs {
var typ string
if err := s.db.QueryRowContext(ctx, `SELECT type FROM notification_channels WHERE id=?`, id).Scan(&typ); err != nil {
return err
}
if typ == "email" {
for _, recipient := range s.recipients(ctx, event) {
personal := event
personal.Recipient = recipient
body, _ := json.Marshal(personal)
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(body), now, now); err != nil {
return err
}
}
continue
}
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
return err
}
}
return nil
}
func (s *Service) recipients(ctx context.Context, event Event) []string {
category := categoryFor(event.Type)
if category == "" {
return nil
}
query := `SELECT DISTINCT u.email FROM users u LEFT JOIN instance_memberships m ON m.user_id=u.id WHERE u.disabled_at IS NULL AND u.email IS NOT NULL AND u.email<>'' AND (u.global_role='admin' OR (?<>'' AND m.instance_id=?)) AND COALESCE((SELECT enabled FROM user_notification_preferences p WHERE p.user_id=u.id AND p.category=?), ?) = 1`
rows, err := s.db.QueryContext(ctx, query, event.InstanceID, event.InstanceID, category, defaultPreferences()[category])
if err != nil {
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var email string
if rows.Scan(&email) == nil {
out = append(out, email)
}
}
return out
}
func categoryFor(typ string) string {
switch {
case strings.HasPrefix(typ, "start."):
return "server_start"
case strings.HasPrefix(typ, "stop."):
return "server_stop"
case strings.HasPrefix(typ, "backup."):
return "backup"
case strings.HasPrefix(typ, "restore."):
return "restore"
case strings.HasPrefix(typ, "update."):
return "update"
case strings.HasSuffix(typ, ".failed"):
return "server_error"
case strings.HasPrefix(typ, "installation_request."):
return "administration"
}
return ""
}
func (s *Service) Test(ctx context.Context, id string) error {
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
}
@@ -218,8 +359,17 @@ func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]stri
if c["username"] != "" {
auth = smtp.PlainAuth("", c["username"], c["password"], host)
}
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
event := Event{}
_ = json.Unmarshal(payload, &event)
to := c["to"]
if to == "" {
to = event.Recipient
}
if to == "" {
return errors.New("email_recipient_required")
}
msg := renderEmail(to, c["from_name"], event)
return sendSMTP(ctx, addr, host, c["tls_mode"], auth, c["from"], strings.Split(to, ","), msg)
}
u, err := url.Parse(c["url"])
if err != nil {
@@ -232,7 +382,15 @@ func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]stri
if typ == "discord" {
var event Event
_ = json.Unmarshal(payload, &event)
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
body = renderDiscord(event)
} else if typ == "gotify" {
var event Event
_ = json.Unmarshal(payload, &event)
u.Path = strings.TrimRight(u.Path, "/") + "/message"
if token := c["token"]; token != "" {
u.RawQuery = "token=" + url.QueryEscape(token)
}
body, _ = json.Marshal(map[string]any{"title": event.Title, "message": renderText(event), "priority": gotifyPriority(event.Severity), "extras": map[string]any{"client::display": map[string]string{"contentType": "text/markdown"}}})
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
@@ -296,9 +454,15 @@ func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Co
return nil, errors.New("delivery_failed")
}
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
func sendSMTP(ctx context.Context, address, host, tlsMode string, auth smtp.Auth, from string, recipients []string, message []byte) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
connection, err := dialer.DialContext(ctx, "tcp", address)
var connection net.Conn
var err error
if tlsMode == "tls" {
connection, err = tls.DialWithDialer(&dialer, "tcp", address, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
} else {
connection, err = dialer.DialContext(ctx, "tcp", address)
}
if err != nil {
return err
}
@@ -308,11 +472,13 @@ func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from st
return err
}
defer func() { _ = client.Close() }()
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("smtp_tls_required")
}
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
if tlsMode == "" || tlsMode == "starttls" {
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("smtp_tls_required")
}
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
}
}
if auth != nil {
if err := client.Auth(auth); err != nil {
@@ -364,14 +530,27 @@ func (s *Service) open(body []byte) (map[string]string, error) {
err = json.Unmarshal(plain, &out)
return out, err
}
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
func validType(v string) bool {
return v == "email" || v == "webhook" || v == "discord" || v == "gotify"
}
func validEvent(v string) bool {
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
return v == "notification.test" || v == "start.completed" || v == "stop.completed" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
}
func validateConfigShape(typ string, c map[string]string) error {
if typ == "email" {
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
return errors.New("email host, from and to are required")
if c["host"] == "" || c["from"] == "" {
return errors.New("email host and from are required")
}
if _, err := mail.ParseAddress(c["from"]); err != nil {
return errors.New("invalid sender email")
}
port, err := strconv.Atoi(c["port"])
if c["port"] != "" && (err != nil || port < 1 || port > 65535) {
return errors.New("invalid smtp port")
}
mode := c["tls_mode"]
if mode != "" && mode != "starttls" && mode != "none" && mode != "tls" {
return errors.New("invalid tls mode")
}
return nil
}
@@ -381,6 +560,72 @@ func validateConfigShape(typ string, c map[string]string) error {
}
return nil
}
func secretKeys(typ string) []string {
switch typ {
case "email":
return []string{"password"}
case "discord":
return []string{"url"}
case "gotify":
return []string{"token"}
case "webhook":
return []string{"signing_secret"}
}
return nil
}
func renderText(e Event) string {
parts := []string{e.Title}
if e.Game != "" {
parts = append(parts, "Game: "+e.Game)
}
if e.InstanceName != "" {
parts = append(parts, "Instance: "+e.InstanceName)
}
if e.Action != "" {
parts = append(parts, "Action: "+e.Action)
}
if e.Message != "" {
parts = append(parts, e.Message)
}
return strings.Join(parts, "\n")
}
func renderEmail(to, name string, e Event) []byte {
subject := e.Title
if subject == "" {
subject = "DoGaMa notification"
}
text := renderText(e)
html := "<html><body><h1>" + htmlEscape(subject) + "</h1>"
if e.Image != "" {
html += "<img src=\"" + htmlEscape(e.Image) + "\" alt=\"game image\" style=\"max-width:360px\">"
}
html += "<p>" + strings.ReplaceAll(htmlEscape(text), "\n", "<br>") + "</p></body></html>"
from := "DoGaMa"
if name != "" {
from = name
}
return []byte("To: " + to + "\r\nFrom: " + from + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=dogama\r\n\r\n--dogama\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n" + text + "\r\n--dogama\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n" + html + "\r\n--dogama--\r\n")
}
func htmlEscape(v string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;").Replace(v)
}
func renderDiscord(e Event) []byte {
embed := map[string]any{"title": e.Title, "description": renderText(e), "timestamp": e.Timestamp.UTC().Format(time.RFC3339)}
if e.Image != "" {
embed["thumbnail"] = map[string]string{"url": e.Image}
}
body, _ := json.Marshal(map[string]any{"embeds": []any{embed}})
return body
}
func gotifyPriority(severity string) int {
if severity == "error" {
return 8
}
if severity == "warning" {
return 5
}
return 3
}
func normalizeEvents(in []string) []string {
seen := map[string]bool{}
out := []string{}
+24
View File
@@ -82,3 +82,27 @@ func TestRejectsMissingKeyAndInsecureURL(t *testing.T) {
t.Fatal("insecure URL accepted")
}
}
func TestPreferencesPersistAndDefaults(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.ExecContext(ctx, `INSERT INTO users(id,username,email,password_hash,global_role,created_at) VALUES('u1','user','user@example.com','x','user','2026-01-01T00:00:00Z')`); err != nil {
t.Fatal(err)
}
s, _ := notification.New(db, bytes.Repeat([]byte{4}, 32))
defaults, err := s.Preferences(ctx, "u1")
if err != nil || !defaults["server_error"] || defaults["server_start"] {
t.Fatalf("unexpected defaults: %#v, %v", defaults, err)
}
if err := s.SetPreferences(ctx, "u1", map[string]bool{"server_start": true}); err != nil {
t.Fatal(err)
}
got, err := s.Preferences(ctx, "u1")
if err != nil || !got["server_start"] || got["server_error"] {
t.Fatalf("unexpected saved values: %#v, %v", got, err)
}
}
+12 -1
View File
@@ -227,13 +227,24 @@ CREATE INDEX configuration_revision_history_idx ON configuration_revisions(insta
CREATE TABLE notification_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord', 'gotify')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
encrypted_config BLOB NOT NULL,
event_filter_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Personal delivery choices are deliberately separate from encrypted global
-- channel configuration. Important failures default to enabled; routine
-- lifecycle events default to disabled.
CREATE TABLE user_notification_preferences (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT NOT NULL CHECK (category IN ('server_start', 'server_stop', 'server_error', 'backup', 'restore', 'update', 'administration')),
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, category)
);
CREATE INDEX user_notification_preferences_user_idx ON user_notification_preferences(user_id);
CREATE TABLE notification_deliveries (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
+29 -2
View File
@@ -1,6 +1,10 @@
package web
import "net/http"
import (
"net/http"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
)
func (s *server) accountPage(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
@@ -16,7 +20,30 @@ func (s *server) accountPage(w http.ResponseWriter, r *http.Request) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
s.render(w, http.StatusOK, "account.html", pageData{Title: "Settings", Language: s.language(r, user.Language), Languages: supportedLanguageOptions(), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "account"})
data := pageData{Title: "Settings", Language: s.language(r, user.Language), Languages: supportedLanguageOptions(), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "account"}
if s.notifications != nil {
data.NotificationPreferences, _ = s.notifications.Preferences(r.Context(), user.ID)
}
s.render(w, http.StatusOK, "account.html", data)
}
func (s *server) accountNotificationsForm(w http.ResponseWriter, r *http.Request) {
id, ok := s.accountForm(w, r)
if !ok {
return
}
if s.notifications == nil {
s.problem(w, http.StatusServiceUnavailable, "Notifications are unavailable.")
return
}
values := map[string]bool{}
for _, category := range notification.Categories {
values[category] = r.FormValue(category) == "on"
}
if err := s.notifications.SetPreferences(r.Context(), id, values); err != nil {
s.problem(w, http.StatusInternalServerError, "Notification preferences could not be saved.")
return
}
http.Redirect(w, r, "/account#notifications", http.StatusSeeOther)
}
func (s *server) accountForm(w http.ResponseWriter, r *http.Request) (string, bool) {
+14 -2
View File
@@ -186,8 +186,8 @@ func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")}
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config})
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "from_name": r.FormValue("from_name"), "to": r.FormValue("to"), "tls_mode": r.FormValue("tls_mode"), "token": r.FormValue("gotify_token")}
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: r.FormValue("enabled") == "on", Events: strings.Fields(r.FormValue("events")), Config: config})
if err != nil {
s.problem(w, 422, "Invalid notification channel.")
return
@@ -195,6 +195,18 @@ func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
http.Redirect(w, r, "/settings#notifications", http.StatusSeeOther)
}
func (s *server) notificationLanguageForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.notifications.SetLanguage(r.Context(), r.FormValue("language")); err != nil {
s.problem(w, http.StatusUnprocessableEntity, "Unsupported notification language.")
return
}
s.recordAudit(r, actor, "notification.language.update", "allowed", map[string]string{"language": r.FormValue("language")})
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
}
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
+58 -38
View File
@@ -80,37 +80,39 @@ type repository interface {
}
type pageData struct {
Title string
Language string
Languages []languageOption
RequireHTTPS bool
CanonicalURL string
CSRFToken string
Error string
User auth.User
GlobalLabels string
IsAdmin bool
Channels []notification.Channel
AuditEvents []audit.Event
AuditPolicy audit.Policy
AuditActor string
AuditInstance string
AuditAction string
AuditOutcome string
ActivePage string
Instances []instance.StoredInstance
Users []auth.User
UserAccess map[string]map[string]authorization.Membership
Permissions []string
StatusCounts map[string]int
RecentActivity []dashboardActivity
SystemStatus dashboardSystemStatus
Catalog []catalog.Summary
CatalogDetail *catalog.Template
CatalogScan *catalog.ScanResult
CatalogScanner bool
Deployment *deploymentPage
InstanceDetail *instanceDetailPage
Title string
Language string
Languages []languageOption
RequireHTTPS bool
CanonicalURL string
CSRFToken string
Error string
User auth.User
GlobalLabels string
IsAdmin bool
Channels []notification.Channel
NotificationPreferences map[string]bool
NotificationLanguage string
AuditEvents []audit.Event
AuditPolicy audit.Policy
AuditActor string
AuditInstance string
AuditAction string
AuditOutcome string
ActivePage string
Instances []instance.StoredInstance
Users []auth.User
UserAccess map[string]map[string]authorization.Membership
Permissions []string
StatusCounts map[string]int
RecentActivity []dashboardActivity
SystemStatus dashboardSystemStatus
Catalog []catalog.Summary
CatalogDetail *catalog.Template
CatalogScan *catalog.ScanResult
CatalogScanner bool
Deployment *deploymentPage
InstanceDetail *instanceDetailPage
}
// instanceDetailPage deliberately contains only values that are safe to render.
@@ -332,6 +334,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
mux.HandleFunc("POST /admin/web-access", s.webAccessForm)
mux.HandleFunc("POST /admin/notification-channels", s.notificationForm)
mux.HandleFunc("POST /admin/notification-language", s.notificationLanguageForm)
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm)
mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm)
@@ -343,6 +346,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("GET /catalog/{id}/deploy", s.deploymentPage)
mux.HandleFunc("POST /catalog/{id}/deploy", s.deploymentSubmit)
mux.HandleFunc("GET /account", s.accountPage)
mux.HandleFunc("POST /account/notifications", s.accountNotificationsForm)
mux.HandleFunc("POST /account/email", s.accountEmailForm)
mux.HandleFunc("POST /account/password", s.accountPasswordForm)
mux.HandleFunc("POST /account/language", s.accountLanguageForm)
@@ -792,11 +796,11 @@ func (s *server) instanceUpdate(w http.ResponseWriter, r *http.Request) {
}
result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID)
if err != nil {
s.queueNotification(r, notification.Event{Type: "update.failed", Title: "Update failed", Message: "The instance update failed.", InstanceName: current.Preview.DisplayName})
s.queueNotification(r, notification.Event{Type: "update.failed", Title: "Update failed", Message: "The instance update failed.", InstanceID: current.ID, InstanceName: current.Preview.DisplayName, Game: current.Preview.Game.Name, Image: current.Preview.Game.ArtworkURL, Action: "update", Severity: "error", Timestamp: time.Now().UTC()})
s.apiProblem(w, 422, "update_failed", err.Error())
return
}
s.queueNotification(r, notification.Event{Type: "update.completed", Title: "Update completed", Message: "The instance update completed.", InstanceName: current.Preview.DisplayName})
s.queueNotification(r, notification.Event{Type: "update.completed", Title: "Update completed", Message: "The instance update completed.", InstanceID: current.ID, InstanceName: current.Preview.DisplayName, Game: current.Preview.Game.Name, Image: current.Preview.Game.ArtworkURL, Action: "update", Timestamp: time.Now().UTC()})
s.apiJSON(w, 200, result)
}
@@ -936,22 +940,28 @@ func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
}
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, authorization.PermissionInstanceStart, s.lifecycle.Start)
s.lifecycleAction(w, r, authorization.PermissionInstanceStart, "start.completed", s.lifecycle.Start)
}
func (s *server) instanceStop(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, authorization.PermissionInstanceStop, s.lifecycle.Stop)
s.lifecycleAction(w, r, authorization.PermissionInstanceStop, "stop.completed", s.lifecycle.Stop)
}
func (s *server) instanceRestart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, authorization.PermissionInstanceRestart, s.lifecycle.Restart)
s.lifecycleAction(w, r, authorization.PermissionInstanceRestart, "start.completed", s.lifecycle.Restart)
}
func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, permission string, action func(context.Context, string) (instance.OperationResult, error)) {
func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, permission, eventType string, action func(context.Context, string) (instance.OperationResult, error)) {
if _, ok := s.requireInstancePermission(w, r, permission); !ok {
return
}
s.runLifecycleAction(w, r, action)
s.runLifecycleAction(w, r, func(ctx context.Context, id string) (instance.OperationResult, error) {
result, err := action(ctx, id)
if err == nil {
s.queueNotification(r, notification.Event{Type: eventType, Title: "Instance lifecycle action completed", Message: "The requested instance action completed.", Action: strings.TrimSuffix(eventType, ".completed")})
}
return result, err
})
}
func (s *server) runLifecycleAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
@@ -1415,6 +1425,15 @@ func (s *server) queueNotification(r *http.Request, event notification.Event) {
if s.notifications == nil {
return
}
if event.Timestamp.IsZero() {
event.Timestamp = time.Now().UTC()
}
if event.InstanceID == "" && s.repository != nil {
if value, err := s.repository.GetInstance(r.Context(), r.PathValue("id")); err == nil {
event.InstanceID, event.InstanceName = value.ID, value.Preview.DisplayName
event.Game, event.Image = value.Preview.Game.Name, value.Preview.Game.ArtworkURL
}
}
if err := s.notifications.Queue(r.Context(), event); err != nil {
s.logger.Warn("notification queue failed", "event", "notification.queue.failed")
}
@@ -2002,6 +2021,7 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
}
if s.notifications != nil {
data.Channels, _ = s.notifications.List(r.Context())
data.NotificationLanguage, _ = s.notifications.Language(r.Context())
}
}
if s.audit != nil {
+1 -1
View File
@@ -1 +1 @@
{{define "account.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Settings · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="app-body">{{template "sidebar" .}}<main class="app-main"><div class="page-heading"><h1>Settings</h1><p>Personal account preferences.</p></div><nav class="tabs"><a href="#profile">Profile</a><a href="#security">Security</a><a href="#language">Language</a></nav><section class="panel settings-section" id="profile"><h2>Profile</h2><p>Signed in as <strong>{{.User.Username}}</strong>.</p><form method="post" action="/account/email"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Email<input name="email" type="email" value="{{.User.Email}}" required autocomplete="email"></label><button type="submit">Save email</button></form></section><section class="panel settings-section" id="security"><h2>Security</h2><form method="post" action="/account/password"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>New password<input name="new_password" type="password" required minlength="12" autocomplete="new-password"></label><label>Confirm new password<input name="confirm_password" type="password" required minlength="12" autocomplete="new-password"></label><button type="submit">Change password</button></form></section><section class="panel settings-section" id="language"><h2>Language</h2><form method="post" action="/account/language"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Language<select name="language">{{range .Languages}}<option value="{{.Code}}"{{if eq $.Language .Code}} selected{{end}}>{{.Display}}</option>{{end}}</select></label><button type="submit">Save language</button></form></section></main></body></html>{{end}}
{{define "account.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Settings · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="app-body">{{template "sidebar" .}}<main class="app-main"><div class="page-heading"><h1>Settings</h1><p>Personal account preferences.</p></div><nav class="tabs"><a href="#profile">Profile</a><a href="#security">Security</a><a href="#language">Language</a><a href="#notifications">Notifications</a></nav><section class="panel settings-section" id="profile"><h2>Profile</h2><p>Signed in as <strong>{{.User.Username}}</strong>.</p><form method="post" action="/account/email"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Email<input name="email" type="email" value="{{.User.Email}}" required autocomplete="email"></label><button type="submit">Save email</button></form></section><section class="panel settings-section" id="security"><h2>Security</h2><form method="post" action="/account/password"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>New password<input name="new_password" type="password" required minlength="12" autocomplete="new-password"></label><label>Confirm new password<input name="confirm_password" type="password" required minlength="12" autocomplete="new-password"></label><button type="submit">Change password</button></form></section><section class="panel settings-section" id="language"><h2>Language</h2><form method="post" action="/account/language"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Language<select name="language">{{range .Languages}}<option value="{{.Code}}"{{if eq $.Language .Code}} selected{{end}}>{{.Display}}</option>{{end}}</select></label><button type="submit">Save language</button></form></section><section class="panel settings-section" id="notifications"><h2>Email notifications</h2><p>Important failures are enabled by default. Routine start, stop and backup messages are disabled until you opt in.</p><form method="post" action="/account/notifications"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label class="check"><input type="checkbox" name="server_start"{{if index .NotificationPreferences "server_start"}} checked{{end}}> Server started</label><label class="check"><input type="checkbox" name="server_stop"{{if index .NotificationPreferences "server_stop"}} checked{{end}}> Server stopped</label><label class="check"><input type="checkbox" name="server_error"{{if index .NotificationPreferences "server_error"}} checked{{end}}> Server errors</label><label class="check"><input type="checkbox" name="backup"{{if index .NotificationPreferences "backup"}} checked{{end}}> Backups</label><label class="check"><input type="checkbox" name="restore"{{if index .NotificationPreferences "restore"}} checked{{end}}> Restores</label><label class="check"><input type="checkbox" name="update"{{if index .NotificationPreferences "update"}} checked{{end}}> Updates</label><label class="check"><input type="checkbox" name="administration"{{if index .NotificationPreferences "administration"}} checked{{end}}> Important administrative actions</label><button type="submit">Save notification preferences</button></form></section></main></body></html>{{end}}
File diff suppressed because one or more lines are too long