Merge pull request 'feat(notifications): make channel configuration persistent' (#48) from codex/block-20-notifications into main
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
Reviewed-on: #48 Reviewed-by: tony <1+tony@noreply.localhost>
This commit was merged in pull request #48.
This commit is contained in:
@@ -31,7 +31,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, 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.
|
||||
- Encrypted write-only SMTP, generic HTTPS webhook, Discord and Gotify channels with explicit disabled-by-default state, persisted safe administration fields, 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.
|
||||
@@ -76,6 +76,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- `DOGAMA_NETWORK` names the application-facing network. `DOGAMA_GAMES_NETWORK` names the single agent-approved network for all created and recreated game containers; API input cannot override either bootstrap boundary.
|
||||
- DoGaMa services never recursively change ownership of application, game-server or backup roots.
|
||||
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
|
||||
- Administration notification forms update one channel at a time. SMTP and Gotify non-secret fields are rendered from the encrypted persisted configuration through an allowlist; SMTP/Discord/Gotify secrets are write-only, with empty edits retaining the existing secret.
|
||||
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
|
||||
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
|
||||
- Session policy is stored in the existing `system_settings` table. `created_at` remains the absolute lifetime anchor, `last_seen_at` tracks inactivity with writes no more often than every five minutes, and logout/deactivation/expiry revoke server-side session rows.
|
||||
|
||||
@@ -29,10 +29,13 @@ import (
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
ID, Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Configured bool
|
||||
ID, Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Configured bool
|
||||
SecretConfigured bool
|
||||
Config map[string]string
|
||||
FormAction string
|
||||
}
|
||||
type Input struct {
|
||||
Name, Type string
|
||||
@@ -101,7 +104,7 @@ func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateConfigShape(in.Type, in.Config); err != nil {
|
||||
if err := validateConfigShape(in.Type, in.Enabled, in.Config); err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
encrypted, err := s.seal(in.Config)
|
||||
@@ -117,7 +120,7 @@ func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, err
|
||||
if err != nil {
|
||||
return Channel{}, fmt.Errorf("save notification channel: %w", err)
|
||||
}
|
||||
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
|
||||
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true, SecretConfigured: hasConfiguredSecret(in.Type, in.Config), Config: safeConfig(in.Type, in.Config), FormAction: "/admin/notification-channels/" + id}, nil
|
||||
}
|
||||
|
||||
// Preferences returns all personal email categories, applying documented
|
||||
@@ -182,7 +185,7 @@ func (s *Service) SetLanguage(ctx context.Context, language string) error {
|
||||
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`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,encrypted_config FROM notification_channels ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,10 +194,19 @@ func (s *Service) List(ctx context.Context) ([]Channel, error) {
|
||||
for rows.Next() {
|
||||
var c Channel
|
||||
var body string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
|
||||
var encrypted []byte
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &encrypted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &c.Events)
|
||||
config, err := s.open(encrypted)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid encrypted notification channel")
|
||||
}
|
||||
c.Configured = len(config) > 0
|
||||
c.SecretConfigured = hasConfiguredSecret(c.Type, config)
|
||||
c.Config = safeConfig(c.Type, config)
|
||||
c.FormAction = "/admin/notification-channels/" + c.ID
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -300,6 +312,9 @@ func (s *Service) queueForChannel(ctx context.Context, id string, event Event) e
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return errors.New("notification channel is disabled")
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
_, 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)
|
||||
@@ -536,7 +551,10 @@ func validType(v string) bool {
|
||||
func validEvent(v string) bool {
|
||||
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 {
|
||||
func validateConfigShape(typ string, enabled bool, c map[string]string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if typ == "email" {
|
||||
if c["host"] == "" || c["from"] == "" {
|
||||
return errors.New("email host and from are required")
|
||||
@@ -544,8 +562,12 @@ func validateConfigShape(typ string, c map[string]string) error {
|
||||
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) {
|
||||
portText := c["port"]
|
||||
if portText == "" {
|
||||
portText = "587"
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return errors.New("invalid smtp port")
|
||||
}
|
||||
mode := c["tls_mode"]
|
||||
@@ -560,6 +582,40 @@ func validateConfigShape(typ string, c map[string]string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasConfiguredSecret(typ string, config map[string]string) bool {
|
||||
for _, key := range secretKeys(typ) {
|
||||
if strings.TrimSpace(config[key]) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func safeConfig(typ string, config map[string]string) map[string]string {
|
||||
keys := []string{}
|
||||
switch typ {
|
||||
case "email":
|
||||
keys = []string{"host", "port", "username", "from", "from_name", "to", "tls_mode"}
|
||||
case "gotify":
|
||||
keys = []string{"url"}
|
||||
}
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value := config[key]; value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if typ == "email" {
|
||||
if _, ok := out["port"]; !ok {
|
||||
out["port"] = "587"
|
||||
}
|
||||
if _, ok := out["tls_mode"]; !ok {
|
||||
out["tls_mode"] = "starttls"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func secretKeys(typ string) []string {
|
||||
switch typ {
|
||||
case "email":
|
||||
|
||||
@@ -190,3 +190,94 @@ func TestPreferencesPersistAndDefaults(t *testing.T) {
|
||||
t.Fatalf("unexpected saved values: %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSettingsPersistWithoutExposingSecrets(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()
|
||||
s, err := notification.New(db, bytes.Repeat([]byte{6}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
smtp, err := s.Upsert(ctx, "", notification.Input{Name: "SMTP", Type: "email", Enabled: false, Events: []string{"start.failed"}, Config: map[string]string{"host": "mail.example.test", "port": "2525", "username": "mailer", "password": "first-secret", "from": "dogama@example.test", "to": "admin@example.test", "tls_mode": "none"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if smtp.Enabled || smtp.Config["host"] != "mail.example.test" || smtp.Config["port"] != "2525" || !smtp.SecretConfigured {
|
||||
t.Fatalf("unexpected saved SMTP view: %#v", smtp)
|
||||
}
|
||||
if _, err := s.Upsert(ctx, smtp.ID, notification.Input{Name: "SMTP", Type: "email", Enabled: true, Events: []string{"start.failed"}, Config: map[string]string{"host": "mail2.example.test", "port": "2526", "from": "dogama@example.test", "tls_mode": "none"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channels, err := s.List(ctx)
|
||||
if err != nil || len(channels) != 1 {
|
||||
t.Fatalf("channels=%#v err=%v", channels, err)
|
||||
}
|
||||
if !channels[0].Enabled || channels[0].Config["host"] != "mail2.example.test" || channels[0].Config["port"] != "2526" || !channels[0].SecretConfigured {
|
||||
t.Fatalf("SMTP edit did not persist safely: %#v", channels[0])
|
||||
}
|
||||
var encrypted []byte
|
||||
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, smtp.ID).Scan(&encrypted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encrypted), "first-secret") {
|
||||
t.Fatal("SMTP secret stored in plaintext")
|
||||
}
|
||||
|
||||
discord, err := s.Upsert(ctx, "", notification.Input{Name: "Discord", Type: "discord", Enabled: false, Config: map[string]string{"url": "https://discord.example.test/webhook/secret"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotify, err := s.Upsert(ctx, "", notification.Input{Name: "Gotify", Type: "gotify", Enabled: false, Config: map[string]string{"url": "https://gotify.example.test", "token": "gotify-secret"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotify.Config["url"] != "https://gotify.example.test" || !gotify.SecretConfigured || discord.Config["url"] != "" || !discord.SecretConfigured {
|
||||
t.Fatalf("unexpected webhook views: discord=%#v gotify=%#v", discord, gotify)
|
||||
}
|
||||
if _, err := s.Upsert(ctx, smtp.ID, notification.Input{Name: "SMTP", Type: "email", Enabled: true, Config: map[string]string{"host": "mail3.example.test", "from": "dogama@example.test", "tls_mode": "none"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channels, err = s.List(ctx)
|
||||
if err != nil || len(channels) != 3 {
|
||||
t.Fatalf("cross-channel list=%#v err=%v", channels, err)
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if channel.ID == gotify.ID && (channel.Config["url"] != "https://gotify.example.test" || !channel.SecretConfigured) {
|
||||
t.Fatalf("Gotify changed while editing SMTP: %#v", channel)
|
||||
}
|
||||
if channel.ID == discord.ID && !channel.SecretConfigured {
|
||||
t.Fatalf("Discord changed while editing SMTP: %#v", channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledChannelDoesNotQueueTest(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()
|
||||
s, _ := notification.New(db, bytes.Repeat([]byte{2}, 32))
|
||||
if _, err := s.Upsert(ctx, "", notification.Input{Name: "invalid SMTP", Type: "email", Enabled: true, Config: map[string]string{"host": "mail.example.test", "port": "not-a-port"}}); err == nil {
|
||||
t.Fatal("enabled SMTP accepted invalid configuration")
|
||||
}
|
||||
channel, err := s.Upsert(ctx, "", notification.Input{Name: "SMTP", Type: "email", Enabled: false, Config: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Test(ctx, channel.ID); err == nil {
|
||||
t.Fatal("disabled channel accepted a test")
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM notification_deliveries`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("disabled test queued %d deliveries", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ CREATE TABLE notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord', 'gotify')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
encrypted_config BLOB NOT NULL,
|
||||
event_filter_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
@@ -195,7 +195,7 @@ func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
|
||||
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"), "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})
|
||||
value, err := s.notifications.Upsert(r.Context(), r.FormValue("id"), 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
|
||||
|
||||
@@ -98,6 +98,7 @@ type pageData struct {
|
||||
SessionPolicy auth.SessionPolicy
|
||||
IsAdmin bool
|
||||
Channels []notification.Channel
|
||||
NotificationForms map[string]notification.Channel
|
||||
NotificationPreferences map[string]bool
|
||||
NotificationLanguage string
|
||||
AuditEvents []auditEventView
|
||||
@@ -2268,6 +2269,16 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.notifications != nil {
|
||||
data.Channels, _ = s.notifications.List(r.Context())
|
||||
data.NotificationForms = map[string]notification.Channel{
|
||||
"email": {Type: "email", Name: "SMTP email", FormAction: "/admin/notification-channels", Config: map[string]string{"port": "587", "tls_mode": "starttls"}},
|
||||
"discord": {Type: "discord", Name: "Discord", FormAction: "/admin/notification-channels"},
|
||||
"gotify": {Type: "gotify", Name: "Gotify", FormAction: "/admin/notification-channels"},
|
||||
}
|
||||
for _, channel := range data.Channels {
|
||||
if _, ok := data.NotificationForms[channel.Type]; ok {
|
||||
data.NotificationForms[channel.Type] = channel
|
||||
}
|
||||
}
|
||||
data.NotificationLanguage, _ = s.notifications.Language(r.Context())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,126 +148,102 @@
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<ul class="channel-list">
|
||||
{{range .Channels}}
|
||||
<li>
|
||||
<strong>
|
||||
{{.Name}}
|
||||
</strong>
|
||||
<small>
|
||||
{{.Type}}
|
||||
·
|
||||
{{if .Enabled}}
|
||||
{{$.Msg "settings.enabled"}}
|
||||
{{else}}
|
||||
{{$.Msg "settings.disabled"}}
|
||||
{{end}}
|
||||
·
|
||||
{{if .Configured}}
|
||||
{{$.Msg "settings.configured"}}
|
||||
{{else}}
|
||||
{{$.Msg "settings.not_configured"}}
|
||||
{{end}}
|
||||
</small>
|
||||
<form method="post" action="/admin/notification-channels/{{.ID}}/test">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit">
|
||||
{{$.Msg "settings.send_test"}}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{{else}}
|
||||
<li>
|
||||
{{.Msg "settings.no_channels"}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<section id="email">
|
||||
<h3>
|
||||
{{.Msg "settings.notification_email"}}
|
||||
</h3>
|
||||
<form method="post" action="/admin/notification-channels">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="SMTP email">
|
||||
<input type="hidden" name="type" value="email">
|
||||
{{with index .NotificationForms "email"}}
|
||||
<form method="post" action="{{.FormAction}}">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<input type="hidden" name="type" value="{{.Type}}">
|
||||
<input type="hidden" name="events" value="backup.completed backup.failed restore.completed restore.failed update.completed update.failed start.completed stop.completed">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
{{.Msg "settings.enabled"}}
|
||||
<input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}>
|
||||
{{$.Msg "settings.enabled"}}
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
{{.Msg "settings.sender_name"}}
|
||||
<input name="from_name">
|
||||
{{$.Msg "settings.sender_name"}}
|
||||
<input name="from_name" value="{{.Config.from_name}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.sender_email"}}
|
||||
<input required type="email" name="from">
|
||||
{{$.Msg "settings.sender_email"}}
|
||||
<input required type="email" name="from" value="{{.Config.from}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.smtp_server"}}
|
||||
<input required name="host">
|
||||
{{$.Msg "settings.smtp_server"}}
|
||||
<input required name="host" value="{{.Config.host}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.port"}}
|
||||
<input required name="port" type="number" min="1" max="65535" value="587">
|
||||
{{$.Msg "settings.port"}}
|
||||
<input required name="port" type="number" min="1" max="65535" value="{{.Config.port}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.username"}}
|
||||
<input name="smtp_username">
|
||||
{{$.Msg "settings.username"}}
|
||||
<input name="smtp_username" value="{{.Config.username}}">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.password"}}
|
||||
{{$.Msg "settings.password"}}
|
||||
<small>
|
||||
{{.Msg "settings.password_retain"}}
|
||||
{{$.Msg "settings.password_retain"}}
|
||||
</small>
|
||||
<input name="smtp_password" type="password">
|
||||
{{if .SecretConfigured}}<small>{{$.Msg "settings.configured"}}</small>{{end}}
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.tls_mode"}}
|
||||
{{$.Msg "settings.tls_mode"}}
|
||||
<select name="tls_mode">
|
||||
<option value="starttls">
|
||||
<option value="starttls"{{if eq .Config.tls_mode "starttls"}} selected{{end}}>
|
||||
STARTTLS
|
||||
</option>
|
||||
<option value="tls">
|
||||
<option value="tls"{{if eq .Config.tls_mode "tls"}} selected{{end}}>
|
||||
Direct TLS / SMTPS
|
||||
</option>
|
||||
<option value="none">
|
||||
<option value="none"{{if eq .Config.tls_mode "none"}} selected{{end}}>
|
||||
No TLS
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.test_recipient"}}
|
||||
<input type="email" name="to">
|
||||
{{$.Msg "settings.test_recipient"}}
|
||||
<input type="email" name="to" value="{{.Config.to}}">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">
|
||||
{{.Msg "settings.save_smtp"}}
|
||||
{{$.Msg "settings.save_smtp"}}
|
||||
</button>
|
||||
</form>
|
||||
{{if .ID}}<form method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">{{$.Msg "settings.send_test"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
<section id="discord">
|
||||
<h3>
|
||||
{{.Msg "settings.notification_discord"}}
|
||||
</h3>
|
||||
<form method="post" action="/admin/notification-channels">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="Discord">
|
||||
<input type="hidden" name="type" value="discord">
|
||||
{{with index .NotificationForms "discord"}}
|
||||
<form method="post" action="{{.FormAction}}">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<input type="hidden" name="type" value="{{.Type}}">
|
||||
<input type="hidden" name="events" value="backup.completed backup.failed restore.completed restore.failed update.completed update.failed">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
{{.Msg "settings.enabled"}}
|
||||
<input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}>
|
||||
{{$.Msg "settings.enabled"}}
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.webhook_url"}}
|
||||
<input required type="url" name="url" placeholder="https://discord.com/api/webhooks/…">
|
||||
{{$.Msg "settings.webhook_url"}}
|
||||
<input type="url" name="url" placeholder="https://discord.com/api/webhooks/…">
|
||||
{{if .SecretConfigured}}<small>{{$.Msg "settings.configured"}}</small>{{end}}
|
||||
</label>
|
||||
<button type="submit">
|
||||
{{.Msg "settings.save_discord"}}
|
||||
{{$.Msg "settings.save_discord"}}
|
||||
</button>
|
||||
</form>
|
||||
{{if .ID}}<form method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">{{$.Msg "settings.send_test"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
<section id="gotify">
|
||||
<h3>
|
||||
@@ -276,27 +252,32 @@
|
||||
<p>
|
||||
{{.Msg "settings.gotify_help"}}
|
||||
</p>
|
||||
<form method="post" action="/admin/notification-channels">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="Gotify">
|
||||
<input type="hidden" name="type" value="gotify">
|
||||
{{with index .NotificationForms "gotify"}}
|
||||
<form method="post" action="{{.FormAction}}">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<input type="hidden" name="type" value="{{.Type}}">
|
||||
<input type="hidden" name="events" value="backup.completed backup.failed restore.completed restore.failed update.completed update.failed">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
{{.Msg "settings.enabled"}}
|
||||
<input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}>
|
||||
{{$.Msg "settings.enabled"}}
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.server_url"}}
|
||||
<input required type="url" name="url" placeholder="https://gotify.example">
|
||||
{{$.Msg "settings.server_url"}}
|
||||
<input required type="url" name="url" value="{{.Config.url}}" placeholder="https://gotify.example">
|
||||
</label>
|
||||
<label>
|
||||
{{.Msg "settings.application_token"}}
|
||||
<input required name="gotify_token" type="password">
|
||||
{{$.Msg "settings.application_token"}}
|
||||
<input name="gotify_token" type="password">
|
||||
{{if .SecretConfigured}}<small>{{$.Msg "settings.configured"}}</small>{{end}}
|
||||
</label>
|
||||
<button type="submit">
|
||||
{{.Msg "settings.save_gotify"}}
|
||||
{{$.Msg "settings.save_gotify"}}
|
||||
</button>
|
||||
</form>
|
||||
{{if .ID}}<form method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">{{$.Msg "settings.send_test"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
</section>
|
||||
<section class="panel settings-section" id="audit">
|
||||
|
||||
Reference in New Issue
Block a user