Compare commits

...
2 Commits
Author SHA1 Message Date
tony 62f2300a46 Merge pull request 'feat(notifications): make channel configuration persistent' (#48) from codex/block-20-notifications into main
CI / validate (push) Canceled after 0s
Reviewed-on: #48
Reviewed-by: tony <1+tony@noreply.localhost>
2026-08-26 01:05:23 +02:00
codex d39df2e7b9 feat(notifications): make channel configuration persistent
CI / validate (pull_request) Successful in 26m24s
2026-08-26 00:49:03 +02:00
7 changed files with 233 additions and 93 deletions
+2 -1
View File
@@ -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.
+67 -11
View File
@@ -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":
+91
View File
@@ -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)
}
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+11
View File
@@ -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())
}
}
+60 -79
View File
@@ -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">