From 0f789821868782fb4d40e3e093c24cab45b30953 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Fri, 10 Apr 2026 01:17:03 +0600 Subject: [PATCH] feat: channel audit logging, name cleaning, message formatting, and dashboard UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add audit log entries for channel create, update, rotate_config, delete - Clean channel names on create/update (trim, lowercase, spaces → hyphens, SafeName validation) - Format chat notifications with full event details (resource, actor, team, timestamp) instead of one-liners - Fix Discord split-line embeds by setting splitLines=No on shoutrrr URL - Add channels dashboard page and sidebar navigation --- frontend/src/lib/api/channels.ts | 72 + frontend/src/lib/components/Sidebar.svelte | 4 +- .../lib/components/icons/IconBroadcast.svelte | 22 + frontend/src/lib/components/icons/index.ts | 1 + .../routes/dashboard/channels/+page.svelte | 1378 +++++++++++++++++ internal/api/handlers_channels.go | 13 +- internal/api/server.go | 2 +- internal/audit/logger.go | 70 + internal/channels/message.go | 54 +- internal/channels/service.go | 26 +- internal/channels/shoutrrr.go | 2 +- 11 files changed, 1624 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/api/channels.ts create mode 100644 frontend/src/lib/components/icons/IconBroadcast.svelte create mode 100644 frontend/src/routes/dashboard/channels/+page.svelte diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts new file mode 100644 index 0000000..130a9a8 --- /dev/null +++ b/frontend/src/lib/api/channels.ts @@ -0,0 +1,72 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type Channel = { + id: string; + team_id: string; + name: string; + provider: string; + events: string[]; + created_at: string; + updated_at: string; + secret?: string; // only present immediately after creation (webhook provider) +}; + +export const PROVIDERS = [ + { value: 'discord', label: 'Discord', fields: ['webhook_url'] }, + { value: 'slack', label: 'Slack', fields: ['webhook_url'] }, + { value: 'teams', label: 'Teams', fields: ['webhook_url'] }, + { value: 'googlechat', label: 'Google Chat', fields: ['webhook_url'] }, + { value: 'telegram', label: 'Telegram', fields: ['bot_token', 'chat_id'] }, + { value: 'matrix', label: 'Matrix', fields: ['homeserver_url', 'access_token', 'room_id'] }, + { value: 'webhook', label: 'Webhook', fields: ['url'] } +] as const; + +export const EVENT_TYPES = [ + { value: 'capsule.created', group: 'Capsule' }, + { value: 'capsule.running', group: 'Capsule' }, + { value: 'capsule.paused', group: 'Capsule' }, + { value: 'capsule.destroyed', group: 'Capsule' }, + { value: 'template.snapshot.created', group: 'Template' }, + { value: 'template.snapshot.deleted', group: 'Template' }, + { value: 'host.up', group: 'Host' }, + { value: 'host.down', group: 'Host' } +] as const; + +export async function listChannels(): Promise> { + return apiFetch('GET', '/api/v1/channels'); +} + +export async function createChannel( + name: string, + provider: string, + config: Record, + events: string[] +): Promise> { + return apiFetch('POST', '/api/v1/channels', { name, provider, config, events }); +} + +export async function updateChannel( + id: string, + name: string, + events: string[] +): Promise> { + return apiFetch('PATCH', `/api/v1/channels/${id}`, { name, events }); +} + +export async function deleteChannel(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/channels/${id}`); +} + +export async function rotateConfig( + id: string, + config: Record +): Promise> { + return apiFetch('PUT', `/api/v1/channels/${id}/config`, { config }); +} + +export async function testChannel( + provider: string, + config: Record +): Promise> { + return apiFetch('POST', '/api/v1/channels/test', { provider, config }); +} diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 06a8a56..4111dd8 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -22,7 +22,8 @@ IconAudit, IconServer, IconShield, - IconMetrics + IconMetrics, + IconBroadcast } from './icons'; let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); @@ -58,6 +59,7 @@ let managementItems = $derived([ { label: 'Keys', icon: IconKey, href: '/dashboard/keys' }, + { label: 'Channels', icon: IconBroadcast, href: '/dashboard/channels' }, { label: 'Team', icon: IconMembers, href: '/dashboard/team' }, { label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' }, ...(currentTeamIsByoc diff --git a/frontend/src/lib/components/icons/IconBroadcast.svelte b/frontend/src/lib/components/icons/IconBroadcast.svelte new file mode 100644 index 0000000..4ed7697 --- /dev/null +++ b/frontend/src/lib/components/icons/IconBroadcast.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index babf0a5..8e256f1 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -27,3 +27,4 @@ export { default as IconServer } from './IconServer.svelte'; export { default as IconGear } from './IconGear.svelte'; export { default as IconShield } from './IconShield.svelte'; export { default as IconMetrics } from './IconMetrics.svelte'; +export { default as IconBroadcast } from './IconBroadcast.svelte'; diff --git a/frontend/src/routes/dashboard/channels/+page.svelte b/frontend/src/routes/dashboard/channels/+page.svelte new file mode 100644 index 0000000..62a9e26 --- /dev/null +++ b/frontend/src/routes/dashboard/channels/+page.svelte @@ -0,0 +1,1378 @@ + + + + Wrenn — Channels + + + + { + if (e.key === 'Escape') { + if (openDropdownId) { openDropdownId = null; return; } + if (creating || editing || deleting || rotating || testing) return; + if (showCreate) { showCreate = false; return; } + if (revealChannel) { revealChannel = null; return; } + editTarget = null; + deleteTarget = null; + rotateTarget = null; + } + }} + onclick={(e) => { + if (openDropdownId && !(e.target as Element)?.closest('.split-btn-container')) { + openDropdownId = null; + } + }} +/> + +
+ + +
+
+ +
+
+
+

+ Channels +

+

+ Route capsule events to Discord, Slack, Telegram, and other destinations. +

+
+ + +
+ +
+
+ + +
+ {#if error} +
+ + + + {error}. Try refreshing the page. +
+ {/if} + + {#if loading} + +
+
+
+
+
+
Channel
+
Provider
+
Events
+
Updated
+
Actions
+
+ {#each Array(3) as _, i} +
+
+
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if channels.length === 0} + +
+
+
+
+ + + + + + + +
+
+

No channels yet

+

Channels deliver capsule events to your team's tools. Connect Discord, Slack, or a custom webhook.

+ +
+ {:else} + +
+ + {channels.length} {channels.length === 1 ? 'channel' : 'channels'} total + +
+ + +
+ +
+
Channel
+
Provider
+
Events
+
Updated
+
Actions
+
+ + + {#each channels as ch, i (ch.id)} +
+
+ + +
+ {ch.name} +
{ch.id}
+
+ + +
+ + {@render providerIcon(ch.provider)} + {providerLabel(ch.provider)} + +
+ + +
+ {#if ch.events.length <= 3} + {#each ch.events as ev} + {ev} + {/each} + {:else} + {#each ch.events.slice(0, 2) as ev} + {ev} + {/each} + +{ch.events.length - 2} + {/if} +
+ + +
+ + {timeAgo(ch.updated_at)} + +
+ + +
+
+ + + +
+ + +
+
+
+ {/each} +
+ {/if} +
+
+ + +
+
+ + All systems operational +
+
+
+
+ + +{#if openDropdownId} + {@const dropdownChannel = channels.find((c) => c.id === openDropdownId)} + {#if dropdownChannel} +
+ +
+ +
+ {/if} +{/if} + + +{#if showCreate} +
+ +
{ if (!creating && !testing) showCreate = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !creating && !testing) showCreate = false; }} + >
+ +
+ + +
+
+ + {#if createStep === 2} + + + + {:else} + 1 + {/if} + + Connection +
+
+
+ + 2 + + Events +
+
+ + {#if createError} +
+ {createError} +
+ {/if} + + {#if createStep === 1} + +

New Channel

+

Name the channel, pick a provider, and enter its connection details.

+ + +
+ + +
+ + +
+ +
+ + + {#if providerDropdownOpen} +
+ {#each PROVIDERS as p} + + {/each} +
+ {/if} +
+
+ + +
+ {#each selectedProvider.fields as field} +
+ + { createConfig = { ...createConfig, [field]: e.currentTarget.value }; }} + disabled={creating} + class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-meta text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] placeholder:font-sans transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60" + /> +
+ {/each} + + {#if createProvider === 'webhook'} +
+ + { createConfig = { ...createConfig, secret: e.currentTarget.value }; }} + disabled={creating} + class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-meta text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] placeholder:font-sans transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60" + /> +
+ {/if} +
+ + +
+ + +
+ + +
+
+ + {:else} + +

Choose Events

+

+ Pick the events that trigger a notification to + {createName} + via {providerLabel(createProvider)}. +

+ + +
+ +
+ + + {#if eventsDropdownOpen} +
+ {#each Object.entries(groupedEvents) as [group, events], gi} +
{group}
+ + {#each events as et} + {@const checked = createEvents.includes(et.value)} + + {/each} + + {#if gi < Object.entries(groupedEvents).length - 1} +
+ {/if} + {/each} +
+ {/if} +
+
+ + + {#if createEvents.length > 0} +
+ {#each createEvents as ev} + + {ev} + + + {/each} +
+ {/if} + + +
+ + + +
+ {/if} +
+
+{/if} + + +{#if revealChannel} +
+ +
{ if (e.key === 'Escape') dismissReveal(); }} + >
+ +
+ +
+ + + + + + Channel created +
+ +

{revealChannel.name}

+

+ Copy the webhook signing secret now — it won't be shown again. +

+ + +
+
+ + {revealChannel.secret ?? ''} + + {#key copyCount} + + {/key} +
+
+ + +
+ + + + +

+ Use this secret to verify webhook signatures (HMAC-SHA256). It cannot be retrieved after you close this dialog. +

+
+ +
+ +
+
+
+{/if} + + +{#if editTarget} +
+ +
{ if (!editing) editTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !editing) editTarget = null; }} + >
+ +
+

Edit Channel

+

+ Update the name or subscribed events. To change the provider, delete this channel and create a new one. +

+ +
+ + {@render providerIcon(editTarget.provider)} + {providerLabel(editTarget.provider)} + +
+ + {#if editError} +
+ {editError} +
+ {/if} + + +
+ + +
+ + +
+ +
+ + + {#if editEventsDropdownOpen} +
+ {#each Object.entries(groupedEvents) as [group, events], gi} +
{group}
+ + {#each events as et} + {@const checked = editEvents.includes(et.value)} + + {/each} + + {#if gi < Object.entries(groupedEvents).length - 1} +
+ {/if} + {/each} +
+ {/if} +
+
+ +
+ + +
+
+
+{/if} + + +{#if deleteTarget} +
+ +
{ if (!deleting) deleteTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} + >
+ +
+

Delete Channel

+

+ Permanently delete {deleteTarget.name}? + Events will stop being delivered to this destination immediately. +

+ + {@render providerIcon(deleteTarget.provider)} + {providerLabel(deleteTarget.provider)} + + + {#if deleteError} +
+ {deleteError} +
+ {/if} + +
+ + +
+
+
+{/if} + + +{#if rotateTarget} +
+ +
{ if (!rotating) rotateTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !rotating) rotateTarget = null; }} + >
+ +
+

+ {rotateTarget.provider === 'webhook' ? 'Rotate Signing Secret' : 'Rotate Credentials'} +

+

+ {#if rotateTarget.provider === 'webhook'} + Replace the HMAC signing secret for {rotateTarget.name}. The webhook URL stays the same. + {:else} + Replace the connection credentials for {rotateTarget.name}. This takes effect immediately. + {/if} +

+ +
+ + {@render providerIcon(rotateTarget.provider)} + {providerLabel(rotateTarget.provider)} + +
+ + {#if rotateError} +
+ {rotateError} +
+ {/if} + +
+ {#each rotateFieldsFor(rotateTarget) as field} +
+ + { rotateConfig_ = { ...rotateConfig_, [field]: e.currentTarget.value }; }} + disabled={rotating} + class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-meta text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] placeholder:font-sans transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60" + /> +
+ {/each} +
+ + +
+ + + + +

+ {#if rotateTarget.provider === 'webhook'} + Your endpoint must verify signatures with the new secret. Old signatures will fail immediately. + {:else} + Old credentials stop working immediately. Make sure the new values are configured in your destination first. + {/if} +

+
+ +
+ + +
+
+
+{/if} + +{#snippet providerIcon(provider: string)} + {#if provider === 'discord'} + + {:else if provider === 'slack'} + + {:else if provider === 'telegram'} + + {:else if provider === 'webhook'} + + {:else} + + {/if} +{/snippet} + + diff --git a/internal/api/handlers_channels.go b/internal/api/handlers_channels.go index 3a13998..0fd1d8e 100644 --- a/internal/api/handlers_channels.go +++ b/internal/api/handlers_channels.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/channels" "git.omukk.dev/wrenn/sandbox/internal/db" @@ -15,11 +16,12 @@ import ( ) type channelHandler struct { - svc *channels.Service + svc *channels.Service + audit *audit.AuditLogger } -func newChannelHandler(svc *channels.Service) *channelHandler { - return &channelHandler{svc: svc} +func newChannelHandler(svc *channels.Service, al *audit.AuditLogger) *channelHandler { + return &channelHandler{svc: svc, audit: al} } type createChannelRequest struct { @@ -94,6 +96,8 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogChannelCreate(r.Context(), ac, result.Channel.ID, result.Channel.Name, result.Channel.Provider) + resp := channelToResponse(result.Channel) if result.PlaintextSecret != "" { resp.Secret = &result.PlaintextSecret @@ -168,6 +172,7 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogChannelUpdate(r.Context(), ac, channelID) writeJSON(w, http.StatusOK, channelToResponse(ch)) } @@ -212,6 +217,7 @@ func (h *channelHandler) RotateConfig(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogChannelRotateConfig(r.Context(), ac, channelID) writeJSON(w, http.StatusOK, channelToResponse(ch)) } @@ -231,5 +237,6 @@ func (h *channelHandler) Delete(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogChannelDelete(r.Context(), ac, channelID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/server.go b/internal/api/server.go index d306d58..fb80f5a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -71,7 +71,7 @@ func New( statsH := newStatsHandler(statsSvc) metricsH := newSandboxMetricsHandler(queries, pool) buildH := newBuildHandler(buildSvc, queries, pool) - channelH := newChannelHandler(channelSvc) + channelH := newChannelHandler(channelSvc, al) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) diff --git a/internal/audit/logger.go b/internal/audit/logger.go index 281aca0..55110ac 100644 --- a/internal/audit/logger.go +++ b/internal/audit/logger.go @@ -281,6 +281,76 @@ func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, te }) } +// --- Channel events (scope: team) --- + +func (l *AuditLogger) LogChannelCreate(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID, name, provider string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "channel", + ResourceID: optText(id.FormatChannelID(channelID)), + Action: "create", + Scope: "team", + Status: "success", + Metadata: marshalMeta(map[string]any{"name": name, "provider": provider}), + }) +} + +func (l *AuditLogger) LogChannelUpdate(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "channel", + ResourceID: optText(id.FormatChannelID(channelID)), + Action: "update", + Scope: "team", + Status: "info", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogChannelRotateConfig(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "channel", + ResourceID: optText(id.FormatChannelID(channelID)), + Action: "rotate_config", + Scope: "team", + Status: "info", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogChannelDelete(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "channel", + ResourceID: optText(id.FormatChannelID(channelID)), + Action: "delete", + Scope: "team", + Status: "warning", + Metadata: []byte("{}"), + }) +} + // --- API key events (scope: team) --- func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID, keyName string) { diff --git a/internal/channels/message.go b/internal/channels/message.go index 9435260..f786281 100644 --- a/internal/channels/message.go +++ b/internal/channels/message.go @@ -2,30 +2,64 @@ package channels import ( "fmt" + "strings" "git.omukk.dev/wrenn/sandbox/internal/events" ) -// FormatMessage produces a compact notification string for chat providers. +// FormatMessage produces a human-readable notification string containing +// the event summary, resource details, actor, and timestamp. func FormatMessage(e events.Event) string { + var b strings.Builder + + b.WriteString(formatSummary(e)) + fmt.Fprintf(&b, "\n\nEvent: %s", e.Event) + fmt.Fprintf(&b, "\nResource: %s %s", e.Resource.Type, e.Resource.ID) + fmt.Fprintf(&b, "\nActor: %s", formatActor(e.Actor)) + fmt.Fprintf(&b, "\nTeam: %s", e.TeamID) + fmt.Fprintf(&b, "\nTime: %s", e.Timestamp) + + return b.String() +} + +func formatSummary(e events.Event) string { switch e.Event { case events.CapsuleCreated: - return fmt.Sprintf("[%s] Capsule %s created", e.Event, e.Resource.ID) + return fmt.Sprintf("Capsule %s created", e.Resource.ID) case events.CapsuleRunning: - return fmt.Sprintf("[%s] Capsule %s is running", e.Event, e.Resource.ID) + return fmt.Sprintf("Capsule %s is running", e.Resource.ID) case events.CapsulePaused: - return fmt.Sprintf("[%s] Capsule %s paused", e.Event, e.Resource.ID) + return fmt.Sprintf("Capsule %s paused", e.Resource.ID) case events.CapsuleDestroyed: - return fmt.Sprintf("[%s] Capsule %s destroyed", e.Event, e.Resource.ID) + return fmt.Sprintf("Capsule %s destroyed", e.Resource.ID) case events.SnapshotCreated: - return fmt.Sprintf("[%s] Template snapshot %s created", e.Event, e.Resource.ID) + return fmt.Sprintf("Template snapshot %s created", e.Resource.ID) case events.SnapshotDeleted: - return fmt.Sprintf("[%s] Template snapshot %s deleted", e.Event, e.Resource.ID) + return fmt.Sprintf("Template snapshot %s deleted", e.Resource.ID) case events.HostUp: - return fmt.Sprintf("[%s] Host %s is up", e.Event, e.Resource.ID) + return fmt.Sprintf("Host %s is up", e.Resource.ID) case events.HostDown: - return fmt.Sprintf("[%s] Host %s is down", e.Event, e.Resource.ID) + return fmt.Sprintf("Host %s is down", e.Resource.ID) default: - return fmt.Sprintf("[%s] %s %s", e.Event, e.Resource.Type, e.Resource.ID) + return fmt.Sprintf("%s %s", e.Resource.Type, e.Resource.ID) + } +} + +func formatActor(a events.Actor) string { + switch a.Type { + case events.ActorSystem: + return "system" + case events.ActorUser: + if a.Name != "" { + return fmt.Sprintf("%s (%s)", a.Name, a.ID) + } + return a.ID + case events.ActorAPIKey: + if a.Name != "" { + return fmt.Sprintf("api_key %s (%s)", a.Name, a.ID) + } + return fmt.Sprintf("api_key %s", a.ID) + default: + return string(a.Type) } } diff --git a/internal/channels/service.go b/internal/channels/service.go index ba7b5ed..7f2652c 100644 --- a/internal/channels/service.go +++ b/internal/channels/service.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -15,6 +16,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/events" "git.omukk.dev/wrenn/sandbox/internal/id" + "git.omukk.dev/wrenn/sandbox/internal/validate" ) // Valid providers. @@ -72,9 +74,11 @@ type CreateResult struct { // Create creates a new notification channel. func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, error) { - if p.Name == "" { - return CreateResult{}, fmt.Errorf("invalid: channel name is required") + clean, err := cleanName(p.Name) + if err != nil { + return CreateResult{}, err } + p.Name = clean if !validProviders[p.Provider] { return CreateResult{}, fmt.Errorf("invalid: unsupported provider %q", p.Provider) @@ -154,9 +158,11 @@ func (s *Service) Get(ctx context.Context, channelID, teamID pgtype.UUID) (db.Ch // Update updates a channel's name and event types. func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, name string, eventTypes []string) (db.Channel, error) { - if name == "" { - return db.Channel{}, fmt.Errorf("invalid: channel name is required") + clean, err := cleanName(name) + if err != nil { + return db.Channel{}, err } + name = clean if len(eventTypes) == 0 { return db.Channel{}, fmt.Errorf("invalid: at least one event type is required") @@ -271,6 +277,18 @@ func (s *Service) Delete(ctx context.Context, channelID, teamID pgtype.UUID) err return s.DB.DeleteChannelByTeam(ctx, db.DeleteChannelByTeamParams{ID: channelID, TeamID: teamID}) } +// cleanName normalises a channel name: trim whitespace, lowercase, replace +// spaces with hyphens, then validate against SafeName rules. +func cleanName(name string) (string, error) { + name = strings.TrimSpace(name) + name = strings.ToLower(name) + name = strings.ReplaceAll(name, " ", "-") + if err := validate.SafeName(name); err != nil { + return "", fmt.Errorf("invalid: %w", err) + } + return name, nil +} + func generateSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/internal/channels/shoutrrr.go b/internal/channels/shoutrrr.go index f173e07..d7f4557 100644 --- a/internal/channels/shoutrrr.go +++ b/internal/channels/shoutrrr.go @@ -39,7 +39,7 @@ func discordURL(config map[string]string) (string, error) { return "", fmt.Errorf("unexpected discord webhook URL format") } webhookID, token := parts[2], parts[3] - return fmt.Sprintf("discord://%s@%s", token, webhookID), nil + return fmt.Sprintf("discord://%s@%s?splitLines=No", token, webhookID), nil } // slackURL converts https://hooks.slack.com/services/T.../B.../XXX → slack://T.../B.../XXX