forked from wrenn/wrenn
feat: channel audit logging, name cleaning, message formatting, and dashboard UI
- 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
This commit is contained in:
72
frontend/src/lib/api/channels.ts
Normal file
72
frontend/src/lib/api/channels.ts
Normal file
@ -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<ApiResult<Channel[]>> {
|
||||||
|
return apiFetch('GET', '/api/v1/channels');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChannel(
|
||||||
|
name: string,
|
||||||
|
provider: string,
|
||||||
|
config: Record<string, string>,
|
||||||
|
events: string[]
|
||||||
|
): Promise<ApiResult<Channel>> {
|
||||||
|
return apiFetch('POST', '/api/v1/channels', { name, provider, config, events });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChannel(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
events: string[]
|
||||||
|
): Promise<ApiResult<Channel>> {
|
||||||
|
return apiFetch('PATCH', `/api/v1/channels/${id}`, { name, events });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChannel(id: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('DELETE', `/api/v1/channels/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateConfig(
|
||||||
|
id: string,
|
||||||
|
config: Record<string, string>
|
||||||
|
): Promise<ApiResult<Channel>> {
|
||||||
|
return apiFetch('PUT', `/api/v1/channels/${id}/config`, { config });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testChannel(
|
||||||
|
provider: string,
|
||||||
|
config: Record<string, string>
|
||||||
|
): Promise<ApiResult<{ status: string }>> {
|
||||||
|
return apiFetch('POST', '/api/v1/channels/test', { provider, config });
|
||||||
|
}
|
||||||
@ -22,7 +22,8 @@
|
|||||||
IconAudit,
|
IconAudit,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconShield,
|
IconShield,
|
||||||
IconMetrics
|
IconMetrics,
|
||||||
|
IconBroadcast
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
|
||||||
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
|
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
|
||||||
@ -58,6 +59,7 @@
|
|||||||
|
|
||||||
let managementItems = $derived<NavItem[]>([
|
let managementItems = $derived<NavItem[]>([
|
||||||
{ label: 'Keys', icon: IconKey, href: '/dashboard/keys' },
|
{ label: 'Keys', icon: IconKey, href: '/dashboard/keys' },
|
||||||
|
{ label: 'Channels', icon: IconBroadcast, href: '/dashboard/channels' },
|
||||||
{ label: 'Team', icon: IconMembers, href: '/dashboard/team' },
|
{ label: 'Team', icon: IconMembers, href: '/dashboard/team' },
|
||||||
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' },
|
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' },
|
||||||
...(currentTeamIsByoc
|
...(currentTeamIsByoc
|
||||||
|
|||||||
22
frontend/src/lib/components/icons/IconBroadcast.svelte
Normal file
22
frontend/src/lib/components/icons/IconBroadcast.svelte
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
<path d="M16.24 7.76a6 6 0 0 1 0 8.49" />
|
||||||
|
<path d="M7.76 16.24a6 6 0 0 1 0-8.49" />
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||||
|
<path d="M4.93 19.07a10 10 0 0 1 0-14.14" />
|
||||||
|
</svg>
|
||||||
@ -27,3 +27,4 @@ export { default as IconServer } from './IconServer.svelte';
|
|||||||
export { default as IconGear } from './IconGear.svelte';
|
export { default as IconGear } from './IconGear.svelte';
|
||||||
export { default as IconShield } from './IconShield.svelte';
|
export { default as IconShield } from './IconShield.svelte';
|
||||||
export { default as IconMetrics } from './IconMetrics.svelte';
|
export { default as IconMetrics } from './IconMetrics.svelte';
|
||||||
|
export { default as IconBroadcast } from './IconBroadcast.svelte';
|
||||||
|
|||||||
1378
frontend/src/routes/dashboard/channels/+page.svelte
Normal file
1378
frontend/src/routes/dashboard/channels/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/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/auth"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/channels"
|
"git.omukk.dev/wrenn/sandbox/internal/channels"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/db"
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
@ -16,10 +17,11 @@ import (
|
|||||||
|
|
||||||
type channelHandler struct {
|
type channelHandler struct {
|
||||||
svc *channels.Service
|
svc *channels.Service
|
||||||
|
audit *audit.AuditLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChannelHandler(svc *channels.Service) *channelHandler {
|
func newChannelHandler(svc *channels.Service, al *audit.AuditLogger) *channelHandler {
|
||||||
return &channelHandler{svc: svc}
|
return &channelHandler{svc: svc, audit: al}
|
||||||
}
|
}
|
||||||
|
|
||||||
type createChannelRequest struct {
|
type createChannelRequest struct {
|
||||||
@ -94,6 +96,8 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.audit.LogChannelCreate(r.Context(), ac, result.Channel.ID, result.Channel.Name, result.Channel.Provider)
|
||||||
|
|
||||||
resp := channelToResponse(result.Channel)
|
resp := channelToResponse(result.Channel)
|
||||||
if result.PlaintextSecret != "" {
|
if result.PlaintextSecret != "" {
|
||||||
resp.Secret = &result.PlaintextSecret
|
resp.Secret = &result.PlaintextSecret
|
||||||
@ -168,6 +172,7 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.audit.LogChannelUpdate(r.Context(), ac, channelID)
|
||||||
writeJSON(w, http.StatusOK, channelToResponse(ch))
|
writeJSON(w, http.StatusOK, channelToResponse(ch))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,6 +217,7 @@ func (h *channelHandler) RotateConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.audit.LogChannelRotateConfig(r.Context(), ac, channelID)
|
||||||
writeJSON(w, http.StatusOK, channelToResponse(ch))
|
writeJSON(w, http.StatusOK, channelToResponse(ch))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,5 +237,6 @@ func (h *channelHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.audit.LogChannelDelete(r.Context(), ac, channelID)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,7 +71,7 @@ func New(
|
|||||||
statsH := newStatsHandler(statsSvc)
|
statsH := newStatsHandler(statsSvc)
|
||||||
metricsH := newSandboxMetricsHandler(queries, pool)
|
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||||
buildH := newBuildHandler(buildSvc, queries, pool)
|
buildH := newBuildHandler(buildSvc, queries, pool)
|
||||||
channelH := newChannelHandler(channelSvc)
|
channelH := newChannelHandler(channelSvc, al)
|
||||||
|
|
||||||
// OpenAPI spec and docs.
|
// OpenAPI spec and docs.
|
||||||
r.Get("/openapi.yaml", serveOpenAPI)
|
r.Get("/openapi.yaml", serveOpenAPI)
|
||||||
|
|||||||
@ -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) ---
|
// --- API key events (scope: team) ---
|
||||||
|
|
||||||
func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID, keyName string) {
|
func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID, keyName string) {
|
||||||
|
|||||||
@ -2,30 +2,64 @@ package channels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/events"
|
"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 {
|
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 {
|
switch e.Event {
|
||||||
case events.CapsuleCreated:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
@ -15,6 +16,7 @@ import (
|
|||||||
"git.omukk.dev/wrenn/sandbox/internal/db"
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/events"
|
"git.omukk.dev/wrenn/sandbox/internal/events"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/id"
|
"git.omukk.dev/wrenn/sandbox/internal/id"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Valid providers.
|
// Valid providers.
|
||||||
@ -72,9 +74,11 @@ type CreateResult struct {
|
|||||||
|
|
||||||
// Create creates a new notification channel.
|
// Create creates a new notification channel.
|
||||||
func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, error) {
|
func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, error) {
|
||||||
if p.Name == "" {
|
clean, err := cleanName(p.Name)
|
||||||
return CreateResult{}, fmt.Errorf("invalid: channel name is required")
|
if err != nil {
|
||||||
|
return CreateResult{}, err
|
||||||
}
|
}
|
||||||
|
p.Name = clean
|
||||||
|
|
||||||
if !validProviders[p.Provider] {
|
if !validProviders[p.Provider] {
|
||||||
return CreateResult{}, fmt.Errorf("invalid: unsupported provider %q", 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.
|
// 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) {
|
func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, name string, eventTypes []string) (db.Channel, error) {
|
||||||
if name == "" {
|
clean, err := cleanName(name)
|
||||||
return db.Channel{}, fmt.Errorf("invalid: channel name is required")
|
if err != nil {
|
||||||
|
return db.Channel{}, err
|
||||||
}
|
}
|
||||||
|
name = clean
|
||||||
|
|
||||||
if len(eventTypes) == 0 {
|
if len(eventTypes) == 0 {
|
||||||
return db.Channel{}, fmt.Errorf("invalid: at least one event type is required")
|
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})
|
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 {
|
func generateSecret() string {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func discordURL(config map[string]string) (string, error) {
|
|||||||
return "", fmt.Errorf("unexpected discord webhook URL format")
|
return "", fmt.Errorf("unexpected discord webhook URL format")
|
||||||
}
|
}
|
||||||
webhookID, token := parts[2], parts[3]
|
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
|
// slackURL converts https://hooks.slack.com/services/T.../B.../XXX → slack://T.../B.../XXX
|
||||||
|
|||||||
Reference in New Issue
Block a user