feat(notifications): add configurable delivery channels #33
@@ -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.
|
||||
|
||||
@@ -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("&", "&", "<", "<", ">", ">", "\"", """).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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
{{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
Reference in New Issue
Block a user