mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.
- POST/GET/DELETE /notifications/channels + POST .../:id/test which
pushes a real probe so misconfiguration surfaces before alerts depend
on it (502 DELIVERY_FAILED on endpoint errors).
- ntfy: POST to endpoint (topic in path), Title/Tags headers, optional
Bearer token. Gotify: POST {endpoint}/message?token=...
- Delivery is best-effort by design — a dead relay never breaks the
producing flow; one shared 5s-timeout client.
- CLI `notifications channels list|add|test|remove`, MCP tools,
OpenAPI paths + NotificationChannel schema.
- Web settings "Push notifications" section and a Push channels card on
the mobile notifications tab (add/test/remove).
Verified live: channel CRUD, test-ping hit a recording endpoint with
correct headers/body, and a real deployment fanned out "Deployment
succeeded" through the channel end-to-end.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
226 lines
6.8 KiB
Go
226 lines
6.8 KiB
Go
package api
|
|
|
|
// Push notification channels — self-hosted ntfy and Gotify endpoints users
|
|
// attach to their account. insertUserNotification fans out best-effort.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// jarvis: ceiling — one shared client; no retry/backoff, delivery is
|
|
// best-effort and failures are silent by design (notifications must never
|
|
// break the producing flow).
|
|
var pushHTTPClient = &http.Client{Timeout: 5 * time.Second}
|
|
|
|
type notificationChannelDTO struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Endpoint string `json:"endpoint"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func mapChannel(row sqlcdb.NotificationChannel) notificationChannelDTO {
|
|
return notificationChannelDTO{
|
|
ID: row.ID.String(),
|
|
Kind: row.Kind,
|
|
Endpoint: row.Endpoint,
|
|
Enabled: row.Enabled,
|
|
CreatedAt: row.CreatedAt.Time.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
func handleListNotificationChannels(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
db := c.MustGet("db").(*database.DB)
|
|
uid, _ := uuid.Parse(userID)
|
|
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list channels")
|
|
return
|
|
}
|
|
out := make([]notificationChannelDTO, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, mapChannel(r))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"channels": out})
|
|
}
|
|
|
|
func handleCreateNotificationChannel(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Kind string `json:"kind" binding:"required,oneof=ntfy gotify"`
|
|
Endpoint string `json:"endpoint" binding:"required"`
|
|
Token string `json:"token"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
respondError(c, http.StatusBadRequest, "VALIDATION", "kind (ntfy|gotify) and endpoint are required")
|
|
return
|
|
}
|
|
endpoint := strings.TrimRight(strings.TrimSpace(req.Endpoint), "/")
|
|
u, err := url.Parse(endpoint)
|
|
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
|
respondError(c, http.StatusBadRequest, "VALIDATION", "endpoint must be a valid http(s) URL")
|
|
return
|
|
}
|
|
db := c.MustGet("db").(*database.DB)
|
|
uid, _ := uuid.Parse(userID)
|
|
row, err := sqlcdb.New(db.DB).InsertNotificationChannel(c.Request.Context(), sqlcdb.InsertNotificationChannelParams{
|
|
UserID: uid,
|
|
Kind: req.Kind,
|
|
Endpoint: endpoint,
|
|
Token: sql.NullString{String: req.Token, Valid: req.Token != ""},
|
|
})
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create channel")
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"channel": mapChannel(row)})
|
|
}
|
|
|
|
func handleDeleteNotificationChannel(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
|
|
return
|
|
}
|
|
db := c.MustGet("db").(*database.DB)
|
|
uid, _ := uuid.Parse(userID)
|
|
n, err := sqlcdb.New(db.DB).DeleteNotificationChannel(c.Request.Context(), sqlcdb.DeleteNotificationChannelParams{
|
|
ID: id, UserID: uid,
|
|
})
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete channel")
|
|
return
|
|
}
|
|
if n == 0 {
|
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Channel deleted"})
|
|
}
|
|
|
|
// handleTestNotificationChannel pushes a probe notification through the
|
|
// channel — surfaces misconfiguration before real alerts depend on it.
|
|
func handleTestNotificationChannel(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
|
|
return
|
|
}
|
|
db := c.MustGet("db").(*database.DB)
|
|
uid, _ := uuid.Parse(userID)
|
|
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load channel")
|
|
return
|
|
}
|
|
var target *sqlcdb.NotificationChannel
|
|
for i := range rows {
|
|
if rows[i].ID == id {
|
|
target = &rows[i]
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
|
|
return
|
|
}
|
|
if err := deliverPush(c.Request.Context(), *target, "test", "Containr test notification", "Your push channel is configured correctly."); err != nil {
|
|
respondError(c, http.StatusBadGateway, "DELIVERY_FAILED", fmt.Sprintf("Push failed: %v", err))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
|
|
}
|
|
|
|
// deliverPush sends one notification through a channel. ntfy expects the
|
|
// body at POST {endpoint} with Title/Priority headers; Gotify takes JSON at
|
|
// POST {endpoint}/message?token=...
|
|
func deliverPush(ctx context.Context, ch sqlcdb.NotificationChannel, kind, title, body string) error {
|
|
var req *http.Request
|
|
var err error
|
|
switch ch.Kind {
|
|
case "ntfy":
|
|
req, err = http.NewRequestWithContext(ctx, http.MethodPost, ch.Endpoint, strings.NewReader(body))
|
|
if err == nil {
|
|
req.Header.Set("Title", title)
|
|
req.Header.Set("Tags", "containr,"+kind)
|
|
if ch.Token.Valid && ch.Token.String != "" {
|
|
req.Header.Set("Authorization", "Bearer "+ch.Token.String)
|
|
}
|
|
}
|
|
case "gotify":
|
|
payload, _ := json.Marshal(map[string]interface{}{
|
|
"title": title,
|
|
"message": body,
|
|
"priority": 5,
|
|
})
|
|
endpoint := ch.Endpoint + "/message"
|
|
if ch.Token.Valid && ch.Token.String != "" {
|
|
endpoint += "?token=" + url.QueryEscape(ch.Token.String)
|
|
}
|
|
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
|
if err == nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported channel kind %q", ch.Kind)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := pushHTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode >= 400 {
|
|
return fmt.Errorf("push endpoint returned %d", res.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fanoutUserPush delivers a notification to every enabled channel the user
|
|
// configured. Errors are swallowed — the in-app row already landed.
|
|
func fanoutUserPush(db *database.DB, userID, kind, title, body string) {
|
|
uid, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rows, err := sqlcdb.New(db.DB).ListNotificationChannelsByUser(context.Background(), uid)
|
|
if err != nil || len(rows) == 0 {
|
|
return
|
|
}
|
|
for _, ch := range rows {
|
|
_ = deliverPush(context.Background(), ch, kind, title, body)
|
|
}
|
|
}
|